From 616f97b9e57f5b92dee37b8030ee628e2ee391cd Mon Sep 17 00:00:00 2001 From: Ryan Goldblatt Date: Wed, 25 Feb 2026 10:24:23 +0000 Subject: [PATCH 1/7] fix bedrock pii redaction null value handling --- .../guardrail_hooks/bedrock_guardrails.py | 36 ++-- .../test_bedrock_guardrails.py | 182 ++++++++++++++++++ 2 files changed, 199 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 8ef188bb23c..fafecf57f31 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -90,13 +90,13 @@ def _redact_pii_matches(response_json: dict) -> dict: # Redact PII entities in sensitive information policy sensitive_info_policy = assessment.get("sensitiveInformationPolicy") if sensitive_info_policy: - pii_entities = sensitive_info_policy.get("piiEntities", []) + pii_entities = sensitive_info_policy.get("piiEntities", []) or [] for pii_entity in pii_entities: if "match" in pii_entity: pii_entity["match"] = "[REDACTED]" # Redact regex matches - regexes = sensitive_info_policy.get("regexes", []) + regexes = sensitive_info_policy.get("regexes", []) or [] for regex_match in regexes: if "match" in regex_match: regex_match["match"] = "[REDACTED]" @@ -104,12 +104,12 @@ def _redact_pii_matches(response_json: dict) -> dict: # Redact custom word matches in word policy word_policy = assessment.get("wordPolicy") if word_policy: - custom_words = word_policy.get("customWords", []) + custom_words = word_policy.get("customWords", []) or [] for custom_word in custom_words: if "match" in custom_word: custom_word["match"] = "[REDACTED]" - managed_words = word_policy.get("managedWordLists", []) + managed_words = word_policy.get("managedWordLists", []) or [] for managed_word in managed_words: if "match" in managed_word: managed_word["match"] = "[REDACTED]" @@ -690,7 +690,7 @@ def _should_raise_guardrail_blocked_exception( # Check topic policy topic_policy = assessment.get("topicPolicy") if topic_policy: - topics = topic_policy.get("topics", []) + topics = topic_policy.get("topics", []) or [] for topic in topics: if topic.get("action") == "BLOCKED": return True @@ -698,7 +698,7 @@ def _should_raise_guardrail_blocked_exception( # Check content policy content_policy = assessment.get("contentPolicy") if content_policy: - filters = content_policy.get("filters", []) + filters = content_policy.get("filters", []) or [] for filter_item in filters: if filter_item.get("action") == "BLOCKED": return True @@ -706,11 +706,11 @@ def _should_raise_guardrail_blocked_exception( # Check word policy word_policy = assessment.get("wordPolicy") if word_policy: - custom_words = word_policy.get("customWords", []) + custom_words = word_policy.get("customWords", []) or [] for custom_word in custom_words: if custom_word.get("action") == "BLOCKED": return True - managed_words = word_policy.get("managedWordLists", []) + managed_words = word_policy.get("managedWordLists", []) or [] for managed_word in managed_words: if managed_word.get("action") == "BLOCKED": return True @@ -718,21 +718,19 @@ def _should_raise_guardrail_blocked_exception( # Check sensitive information policy sensitive_info_policy = assessment.get("sensitiveInformationPolicy") if sensitive_info_policy: - pii_entities = sensitive_info_policy.get("piiEntities", []) - if pii_entities: - for pii_entity in pii_entities: - if pii_entity.get("action") == "BLOCKED": - return True - regexes = sensitive_info_policy.get("regexes", []) - if regexes: - for regex in regexes: - if regex.get("action") == "BLOCKED": - return True + pii_entities = sensitive_info_policy.get("piiEntities", []) or [] + for pii_entity in pii_entities: + if pii_entity.get("action") == "BLOCKED": + return True + regexes = sensitive_info_policy.get("regexes", []) or [] + for regex in regexes: + if regex.get("action") == "BLOCKED": + return True # Check contextual grounding policy contextual_grounding_policy = assessment.get("contextualGroundingPolicy") if contextual_grounding_policy: - grounding_filters = contextual_grounding_policy.get("filters", []) + grounding_filters = contextual_grounding_policy.get("filters", []) or [] for grounding_filter in grounding_filters: if grounding_filter.get("action") == "BLOCKED": return True 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 84d320a0a27..9a04a6ac22a 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 @@ -1189,3 +1189,185 @@ async def test_bedrock_guardrail_blocked_content_with_masking_enabled(): print("✅ BLOCKED content with masking enabled raises exception correctly") + +@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 + From b52d1ec92f653ccd6a590e25250a2a9bab9af823 Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Thu, 26 Feb 2026 08:51:11 +0100 Subject: [PATCH 2/7] fix(bedrock): remove invalid :0 suffix from Claude Opus 4.6 model ID AWS Bedrock does not recognize anthropic.claude-opus-4-6-v1:0 as a valid model identifier. Unlike other Claude models, Opus 4.6 requires the model ID without the :0 version suffix: anthropic.claude-opus-4-6-v1. Cherry-picked from search_tools_fix (efec746a17), adapted since upstream PR #20564 already fixed the JSON pricing keys. Co-Authored-By: Claude Sonnet 4.6 --- litellm/constants.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/constants.py b/litellm/constants.py index 34b6950a214..14d444290aa 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1083,7 +1083,6 @@ "openai.gpt-oss-120b-1:0", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", - "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", From 37a8dabc4fc0c7d2166fb3d3accea24cb04b913d Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Fri, 13 Feb 2026 13:41:52 +0100 Subject: [PATCH 3/7] fix(thinking): recognize adaptive thinking type in is_thinking_enabled Upstream only checks for type="enabled" but Opus 4.6 uses type="adaptive". Without this fix, max_tokens auto-adjustment doesn't trigger for adaptive thinking, causing API errors. --- litellm/llms/base_llm/chat/transformation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index b71ae0fddee..de71b9b7c6e 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -110,8 +110,9 @@ def get_json_schema_from_pydantic_object( return type_to_response_format_param(response_format=response_format) def is_thinking_enabled(self, non_default_params: dict) -> bool: + thinking_type = non_default_params.get("thinking", {}).get("type") return ( - non_default_params.get("thinking", {}).get("type") == "enabled" + thinking_type in ("enabled", "adaptive") or non_default_params.get("reasoning_effort") is not None ) From ea926bae8830ea84a929b424ec262dd26c9ced3c Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Thu, 5 Feb 2026 21:35:19 -0500 Subject: [PATCH 4/7] fix(thinking): drop thinking param when assistant messages have text without thinking blocks Follow-up to a494503f4b which fixed thinking + tool_use. That fix only detected missing thinking blocks on assistant messages with tool_calls. When the last assistant message has plain text content (no tool_calls), the check returned False and thinking was not dropped, causing: "Expected thinking or redacted_thinking, but found text" Add last_assistant_message_has_no_thinking_blocks() to detect any assistant message with content but no thinking blocks. Extract shared _message_has_thinking_blocks() helper that checks both the thinking_blocks field and content array for thinking/redacted_thinking blocks. Co-Authored-By: Claude Opus 4.6 (1M context) --- litellm/llms/anthropic/chat/transformation.py | 10 +- .../bedrock/chat/converse_transformation.py | 10 +- litellm/utils.py | 77 ++++++++- tests/test_litellm/test_utils.py | 156 ++++++++++++++++++ 4 files changed, 240 insertions(+), 13 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1b912bfc2a0..1e9d5ac4a4d 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -63,6 +63,7 @@ any_assistant_message_has_thinking_blocks, get_max_tokens, has_tool_call_blocks, + last_assistant_message_has_no_thinking_blocks, last_assistant_with_tool_calls_has_no_thinking_blocks, supports_reasoning, token_counter, @@ -1341,7 +1342,9 @@ def transform_request( ) # Drop thinking param if thinking is enabled but thinking_blocks are missing - # This prevents the error: "Expected thinking or redacted_thinking, but found tool_use" + # This prevents Anthropic errors: + # - "Expected thinking or redacted_thinking, but found tool_use" (assistant with tool_calls) + # - "Expected thinking or redacted_thinking, but found text" (assistant with text content) # # IMPORTANT: Only drop thinking if NO assistant messages have thinking_blocks. # If any message has thinking_blocks, we must keep thinking enabled, otherwise @@ -1350,7 +1353,10 @@ def transform_request( if ( optional_params.get("thinking") is not None and messages is not None - and last_assistant_with_tool_calls_has_no_thinking_blocks(messages) + and ( + last_assistant_with_tool_calls_has_no_thinking_blocks(messages) + or last_assistant_message_has_no_thinking_blocks(messages) + ) and not any_assistant_message_has_thinking_blocks(messages) ): if litellm.modify_params: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 229457a73b4..a6b137a2967 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -62,6 +62,7 @@ add_dummy_tool, any_assistant_message_has_thinking_blocks, has_tool_call_blocks, + last_assistant_message_has_no_thinking_blocks, last_assistant_with_tool_calls_has_no_thinking_blocks, supports_reasoning, token_counter, @@ -1413,7 +1414,9 @@ def _transform_request_helper( ) # Drop thinking param if thinking is enabled but thinking_blocks are missing - # This prevents the error: "Expected thinking or redacted_thinking, but found tool_use" + # This prevents Anthropic errors: + # - "Expected thinking or redacted_thinking, but found tool_use" (assistant with tool_calls) + # - "Expected thinking or redacted_thinking, but found text" (assistant with text content) # # IMPORTANT: Only drop thinking if NO assistant messages have thinking_blocks. # If any message has thinking_blocks, we must keep thinking enabled, otherwise @@ -1421,7 +1424,10 @@ def _transform_request_helper( if ( optional_params.get("thinking") is not None and messages is not None - and last_assistant_with_tool_calls_has_no_thinking_blocks(messages) + and ( + last_assistant_with_tool_calls_has_no_thinking_blocks(messages) + or last_assistant_message_has_no_thinking_blocks(messages) + ) and not any_assistant_message_has_thinking_blocks(messages) ): if litellm.modify_params: diff --git a/litellm/utils.py b/litellm/utils.py index ca878721197..43585062ad3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7597,6 +7597,33 @@ def has_tool_call_blocks(messages: List[AllMessageValues]) -> bool: return False +def _message_has_thinking_blocks(message: AllMessageValues) -> bool: + """ + Check if a single assistant message has thinking blocks. + + Checks both the 'thinking_blocks' field (LiteLLM/OpenAI format) and + the 'content' array for thinking/redacted_thinking blocks (Anthropic format). + """ + # Check thinking_blocks field (LiteLLM/OpenAI format) + thinking_blocks = message.get("thinking_blocks") + if thinking_blocks is not None and ( + not hasattr(thinking_blocks, "__len__") or len(thinking_blocks) > 0 + ): + return True + + # Check content array for thinking blocks (Anthropic format) + content = message.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") in ( + "thinking", + "redacted_thinking", + ): + return True + + return False + + def any_assistant_message_has_thinking_blocks( messages: List[AllMessageValues], ) -> bool: @@ -7612,10 +7639,7 @@ def any_assistant_message_has_thinking_blocks( """ for message in messages: if message.get("role") == "assistant": - thinking_blocks = message.get("thinking_blocks") - if thinking_blocks is not None and ( - not hasattr(thinking_blocks, "__len__") or len(thinking_blocks) > 0 - ): + if _message_has_thinking_blocks(message): return True return False @@ -7647,11 +7671,46 @@ def last_assistant_with_tool_calls_has_no_thinking_blocks( if last_assistant_with_tools is None: return False - # Check if it has thinking_blocks - thinking_blocks = last_assistant_with_tools.get("thinking_blocks") - return thinking_blocks is None or ( - hasattr(thinking_blocks, "__len__") and len(thinking_blocks) == 0 - ) + return not _message_has_thinking_blocks(last_assistant_with_tools) + + +def last_assistant_message_has_no_thinking_blocks( + messages: List[AllMessageValues], +) -> bool: + """ + Returns true if the last assistant message has content but no thinking_blocks. + + This is used to detect when thinking param should be dropped to avoid + Anthropic error: "Expected thinking or redacted_thinking, but found text" + + When thinking is enabled, ALL assistant messages must start with thinking_blocks. + If the client didn't preserve thinking_blocks, we need to drop the thinking param. + + IMPORTANT: This should only be used in conjunction with + any_assistant_message_has_thinking_blocks() to ensure we don't drop thinking + when other messages in the conversation contain thinking blocks. + """ + # Only relevant if thinking was previously active in this conversation. + # Without prior thinking blocks, a text-only assistant message just means + # thinking was never enabled — not that blocks were stripped. + if not any_assistant_message_has_thinking_blocks(messages): + return False + + # Find the last assistant message + last_assistant = None + for message in messages: + if message.get("role") == "assistant": + last_assistant = message + + if last_assistant is None: + return False + + # Only flag if message has content (empty messages aren't an issue) + content = last_assistant.get("content") + if not content: + return False + + return not _message_has_thinking_blocks(last_assistant) def add_dummy_tool(custom_llm_provider: str) -> List[ChatCompletionToolParam]: diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 64488e2fb6a..0978fbf2aaf 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -3350,6 +3350,162 @@ def test_last_assistant_with_tool_calls_has_no_thinking_blocks_issue_18926(): assert should_drop_thinking is False +def test_last_assistant_message_has_no_thinking_blocks_text_only(): + """ + Test that the function only fires when thinking was previously active. + + A fresh conversation (no prior thinking blocks) must NOT cause thinking to be + dropped — the user may simply be enabling thinking for the first time. + """ + from litellm.utils import ( + any_assistant_message_has_thinking_blocks, + last_assistant_message_has_no_thinking_blocks, + last_assistant_with_tool_calls_has_no_thinking_blocks, + ) + + # Scenario 1: fresh conversation, thinking never used — must NOT drop + messages_no_prior_thinking = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "What's 2+2?"}, + {"role": "assistant", "content": "4"}, + {"role": "user", "content": "Thanks"}, + ] + assert last_assistant_with_tool_calls_has_no_thinking_blocks(messages_no_prior_thinking) is False + assert any_assistant_message_has_thinking_blocks(messages_no_prior_thinking) is False + # Must return False — no evidence thinking was ever enabled + assert last_assistant_message_has_no_thinking_blocks(messages_no_prior_thinking) is False + + # Scenario 2: thinking was used before, but last message has no blocks — MUST drop + messages_with_prior_thinking = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, + {"type": "text", "text": "Hi!"}, + ], + }, + {"role": "user", "content": "What's 2+2?"}, + {"role": "assistant", "content": "4"}, # blocks stripped by client + {"role": "user", "content": "Thanks"}, + ] + assert any_assistant_message_has_thinking_blocks(messages_with_prior_thinking) is True + assert last_assistant_message_has_no_thinking_blocks(messages_with_prior_thinking) is True + + +def test_last_assistant_message_has_no_thinking_blocks_with_content_list(): + """ + Test detection when last assistant has content list but no thinking blocks, + only when prior thinking blocks exist in the conversation. + """ + from litellm.utils import last_assistant_message_has_no_thinking_blocks + + # No prior thinking — should NOT drop + messages_no_prior = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [{"type": "text", "text": "Hi there!"}], + }, + ] + assert last_assistant_message_has_no_thinking_blocks(messages_no_prior) is False + + # Prior thinking exists, last message has none — SHOULD drop + messages_with_prior = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "..."}, + {"type": "text", "text": "First answer"}, + ], + }, + {"role": "user", "content": "Follow up"}, + { + "role": "assistant", + "content": [{"type": "text", "text": "Second answer"}], # blocks stripped + }, + ] + assert last_assistant_message_has_no_thinking_blocks(messages_with_prior) is True + + +def test_last_assistant_message_has_thinking_in_content(): + """ + Test that function returns False when thinking blocks are in content array + (Anthropic format) rather than in the thinking_blocks field. + """ + from litellm.utils import ( + any_assistant_message_has_thinking_blocks, + last_assistant_message_has_no_thinking_blocks, + ) + + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, + {"type": "text", "text": "The answer is 42."}, + ], + }, + ] + + # Content has thinking blocks, so should return False + assert last_assistant_message_has_no_thinking_blocks(messages) is False + + # any_assistant check should also detect thinking blocks in content + assert any_assistant_message_has_thinking_blocks(messages) is True + + +def test_last_assistant_message_no_content(): + """ + Test that function returns False when last assistant has no content. + """ + from litellm.utils import last_assistant_message_has_no_thinking_blocks + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": None}, + ] + + assert last_assistant_message_has_no_thinking_blocks(messages) is False + + +def test_no_assistant_messages(): + """ + Test that function returns False when there are no assistant messages. + """ + from litellm.utils import last_assistant_message_has_no_thinking_blocks + + messages = [ + {"role": "user", "content": "Hello"}, + ] + + assert last_assistant_message_has_no_thinking_blocks(messages) is False + + +def test_thinking_blocks_field_detected_by_any_check(): + """ + Test that any_assistant_message_has_thinking_blocks detects thinking blocks + in both the thinking_blocks field and in the content array. + """ + from litellm.utils import any_assistant_message_has_thinking_blocks + + # Thinking in content array (Anthropic format) + messages_content = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "xxx"}, + {"type": "text", "text": "answer"}, + ], + }, + ] + assert any_assistant_message_has_thinking_blocks(messages_content) is True + + class TestAdditionalDropParamsForNonOpenAIProviders: """ Test additional_drop_params functionality for non-OpenAI providers. From 6609831b74f4f6ec6db4fef568a646d6ba8cf73c Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Thu, 5 Feb 2026 21:32:26 -0500 Subject: [PATCH 5/7] fix(bedrock): strip context_management from request body for all Bedrock APIs Bedrock doesn't support context_management as a request body parameter. The feature is enabled via the anthropic-beta header (context-management-2025-06-27) which was already handled correctly. Leaving context_management in the body causes: "context_management: Extra inputs are not permitted" Strip the parameter from all 3 Bedrock API paths: - Invoke Messages API - Invoke Chat API - Converse API (additionalModelRequestFields) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bedrock/chat/converse_transformation.py | 4 ++ .../anthropic_claude3_transformation.py | 3 ++ .../anthropic_claude3_transformation.py | 5 ++- .../test_websearch_interception_handler.py | 38 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index a6b137a2967..dc3366bd5c6 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1251,6 +1251,10 @@ def _prepare_request_params( # 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. 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 7936b6ea644..e1e4a318599 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -110,6 +110,9 @@ def transform_request( # Bedrock Invoke doesn't support output_config parameter # Fixes: https://github.com/BerriAI/litellm/issues/22797 _anthropic_request.pop("output_config", None) + # Bedrock doesn't support context_management as a body param; + # the feature is enabled via the anthropic-beta header instead + _anthropic_request.pop("context_management", None) if "anthropic_version" not in _anthropic_request: _anthropic_request["anthropic_version"] = self.anthropic_version 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 e31820d7631..18c65188d3d 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -423,12 +423,15 @@ def transform_anthropic_messages_request( # Fixes: https://github.com/BerriAI/litellm/issues/22797 anthropic_messages_request.pop("output_config", None) + # 5c. Remove `context_management` from request body (Bedrock doesn't support it as a body param; + # the feature is enabled via the anthropic-beta header instead) + anthropic_messages_request.pop("context_management", None) + # 5a. Remove `custom` field from tools (Bedrock doesn't support it) # Claude Code sends `custom: {defer_loading: true}` on tool definitions, # which causes Bedrock to reject the request with "Extra inputs are not permitted" # Ref: https://github.com/BerriAI/litellm/issues/22847 remove_custom_field_from_tools(anthropic_messages_request) - # 6. AUTO-INJECT beta headers based on features used anthropic_model_info = AnthropicModelInfo() tools = anthropic_messages_optional_request_params.get("tools") diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index b467822ac70..f374e4e337b 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -102,6 +102,44 @@ async def test_internal_flags_filtered_from_followup_kwargs(): assert kwargs_for_followup["max_tokens"] == 1024 +def test_duplicate_kwargs_filtered_from_followup(): + """Test that kwargs already in optional_params are deduplicated before follow-up request. + + Regression test for bug where context_management appeared in both + optional_params and kwargs, causing: "got multiple values for keyword argument 'context_management'" + """ + + optional_params_without_max_tokens = { + "thinking": {"type": "enabled", "budget_tokens": 5000}, + "context_management": {"type": "automatic", "max_context_tokens": 50000}, + "temperature": 0.7, + } + + kwargs_for_followup = { + "context_management": {"type": "automatic", "max_context_tokens": 50000}, + "some_other_kwarg": "value", + "max_tokens": 1024, + "model": "claude-opus-4-6", + "messages": [{"role": "user", "content": "test"}], + } + + # Apply the same dedup logic used in _execute_agentic_loop + explicit_keys = {"max_tokens", "messages", "model"} + kwargs_for_followup = { + k: v for k, v in kwargs_for_followup.items() + if k not in optional_params_without_max_tokens and k not in explicit_keys + } + + # context_management should be removed (already in optional_params) + assert "context_management" not in kwargs_for_followup + # Explicit keys should be removed + assert "max_tokens" not in kwargs_for_followup + assert "model" not in kwargs_for_followup + assert "messages" not in kwargs_for_followup + # Non-duplicate kwargs should be preserved + assert kwargs_for_followup["some_other_kwarg"] == "value" + + @pytest.mark.asyncio async def test_async_pre_call_deployment_hook_provider_from_top_level_kwargs(): """Test that async_pre_call_deployment_hook finds custom_llm_provider at top-level kwargs. From 0e6b47de9c54eb4077214621777f02bf495fa1d1 Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Thu, 5 Feb 2026 17:52:44 -0500 Subject: [PATCH 6/7] feat(bedrock): centralize beta header filtering with version-based support Standardize anthropic-beta header handling across all Bedrock APIs (Invoke Chat, Converse, Messages) using a centralized whitelist-based filter with version-based model support. - Inconsistent filtering: Invoke Chat used whitelist (safe), Converse/Messages used blacklist (allows unsupported headers through) - Production risk: unsupported headers could cause AWS API errors - Maintenance burden: adding new Claude models required updating multiple hardcoded lists - Centralized BedrockBetaHeaderFilter with whitelist approach - Version-based filtering (e.g., "requires 4.5+") instead of model lists - Family restrictions (opus/sonnet/haiku) when needed - Automatic header translation for backward compatibility - Add `litellm/llms/bedrock/beta_headers_config.py` - BedrockBetaHeaderFilter class - Whitelist of 11 supported beta headers - Version/family restriction logic - Debug logging support - Invoke Chat: Replace local whitelist with centralized filter - Converse: Remove blacklist (30 lines), use whitelist filter - Messages: Remove complex filter (55 lines), preserve translation - Add `tests/test_litellm/llms/bedrock/test_beta_headers_config.py` - 40+ unit tests for filter logic - Extend `tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py` - 13 integration tests for API transformations - Verify filtering, version restrictions, translations - Add `litellm/llms/bedrock/README.md` - Maintenance guide for adding new headers/models - Enhanced module docstrings with examples - Production safety: only whitelisted headers reach AWS - Zero maintenance for new Claude models (Opus 5, Sonnet 5, etc.) - Consistent filtering across all 3 APIs - Preserved backward compatibility (advanced-tool-use translation) ```bash poetry run pytest tests/test_litellm/llms/bedrock/ -v ``` Co-Authored-By: Claude Sonnet 4.5 --- litellm/llms/bedrock/README.md | 67 +++ litellm/llms/bedrock/beta_headers_config.py | 368 ++++++++++++ .../bedrock/chat/converse_transformation.py | 23 +- .../anthropic_claude3_transformation.py | 22 +- .../anthropic_claude3_transformation.py | 86 +-- .../bedrock/test_anthropic_beta_support.py | 532 ++++++++++++++++- .../llms/bedrock/test_beta_headers_config.py | 546 ++++++++++++++++++ 7 files changed, 1532 insertions(+), 112 deletions(-) create mode 100644 litellm/llms/bedrock/README.md create mode 100644 litellm/llms/bedrock/beta_headers_config.py create mode 100644 tests/test_litellm/llms/bedrock/test_beta_headers_config.py 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/beta_headers_config.py b/litellm/llms/bedrock/beta_headers_config.py new file mode 100644 index 00000000000..46b9abd13e8 --- /dev/null +++ b/litellm/llms/bedrock/beta_headers_config.py @@ -0,0 +1,368 @@ +""" +Shared configuration for Bedrock anthropic-beta header handling. + +This module provides centralized whitelist-based filtering for anthropic-beta +headers across all Bedrock APIs (Invoke Chat, Invoke Messages, Converse). + +## Architecture + +All three Bedrock APIs use BedrockBetaHeaderFilter to ensure consistent filtering: +- Invoke Chat API: BedrockAPI.INVOKE_CHAT +- Invoke Messages API: BedrockAPI.INVOKE_MESSAGES (with advanced-tool-use translation) +- Converse API: BedrockAPI.CONVERSE + +## Future-Proof Design + +The filter uses version-based model support instead of hardcoded model lists: +- New Claude models (e.g., Opus 5, Sonnet 5) require ZERO code changes +- Beta headers specify minimum version (e.g., "requires 4.5+") +- Family restrictions (opus/sonnet/haiku) when needed + +## Adding New Beta Headers + +When AWS Bedrock adds support for a new Anthropic beta header: + +**Scenario 1: Works on all models** +```python +BEDROCK_CORE_SUPPORTED_BETAS.add("new-feature-2027-01-15") +# Done! Works on all models automatically. +``` + +**Scenario 2: Requires specific version** +```python +BEDROCK_CORE_SUPPORTED_BETAS.add("advanced-reasoning-2027-06-15") +BETA_HEADER_MINIMUM_VERSION["advanced-reasoning-2027-06-15"] = 5.0 +# Done! Works on all Claude 5.0+ models (Opus, Sonnet, Haiku). +``` + +**Scenario 3: Version + family restriction** +```python +BEDROCK_CORE_SUPPORTED_BETAS.add("ultra-context-2027-12-15") +BETA_HEADER_MINIMUM_VERSION["ultra-context-2027-12-15"] = 5.5 +BETA_HEADER_FAMILY_RESTRICTIONS["ultra-context-2027-12-15"] = ["opus"] +# Done! Works on Opus 5.5+ only. +``` + +**Always add tests** in `tests/test_litellm/llms/bedrock/test_beta_headers_config.py` + +## Testing + +Run the test suite to verify changes: +```bash +poetry run pytest tests/test_litellm/llms/bedrock/test_beta_headers_config.py -v +poetry run pytest tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py -v +``` + +Reference: +- AWS Bedrock Documentation: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html +""" + +import re +from enum import Enum +from typing import Dict, List, Optional, Set + + +class BedrockAPI(Enum): + """Enum for different Bedrock API types.""" + + INVOKE_CHAT = "invoke_chat" + INVOKE_MESSAGES = "invoke_messages" + CONVERSE = "converse" + + +# Core whitelist of beta headers supported by ALL Bedrock APIs +BEDROCK_CORE_SUPPORTED_BETAS: Set[str] = { + "computer-use-2024-10-22", # Legacy computer use + "computer-use-2025-01-24", # Current computer use (Claude 3.7 Sonnet) + "computer-use-2025-11-24", # Latest computer use (Claude Opus 4.5+) + "token-efficient-tools-2025-02-19", # Tool use (Claude 3.7+ and Claude 4+) + "interleaved-thinking-2025-05-14", # Interleaved thinking (Claude 4+) + "output-128k-2025-02-19", # 128K output tokens (Claude 3.7 Sonnet) + "dev-full-thinking-2025-05-14", # Developer mode for raw thinking (Claude 4+) + "context-1m-2025-08-07", # 1 million tokens (Claude Sonnet 4) + "context-management-2025-06-27", # Context management (Claude Sonnet/Haiku 4.5) + "effort-2025-11-24", # Effort parameter (Claude Opus 4.5) + "tool-search-tool-2025-10-19", # Tool search (Claude Opus 4.5) + "tool-examples-2025-10-29", # Tool use examples (Claude Opus 4.5) +} + +# API-specific exclusions (headers NOT supported by specific APIs) +BEDROCK_API_EXCLUSIONS: Dict[BedrockAPI, Set[str]] = { + BedrockAPI.CONVERSE: set(), # No additional exclusions + BedrockAPI.INVOKE_CHAT: set(), # No additional exclusions + BedrockAPI.INVOKE_MESSAGES: set(), # No additional exclusions +} + +# Model version extraction regex pattern +# Matches Bedrock model IDs in both formats: +# New: claude-{family}-{major}-{minor}-{date} (e.g., claude-opus-4-5-20250514-v1:0) +# Legacy: claude-{major}-{minor}-{family}-{date} (e.g., claude-3-5-sonnet-20240620-v1:0) +# Minor version is a single digit followed by a hyphen or end-of-string (to avoid +# capturing the date). The `(?:-|$)` lookahead handles both suffix-less IDs like +# `anthropic.claude-sonnet-4-6` and the more common `...-4-6-20250514-v1:0` form. +MODEL_VERSION_PATTERN = r"claude-(?:(?:opus|sonnet|haiku)-)?(\d+)(?:-(\d)(?:-|$))?" + +# Minimum model version required for each beta header (major.minor format) +# Default behavior: If a beta header is NOT in this dict, it's supported by ALL Anthropic models +# This approach is future-proof - new models automatically support all headers unless excluded +BETA_HEADER_MINIMUM_VERSION: Dict[str, float] = { + # Extended thinking features require Claude 4.0+ + "interleaved-thinking-2025-05-14": 4.0, + "dev-full-thinking-2025-05-14": 4.0, + # 1M context requires Claude 4.0+ + "context-1m-2025-08-07": 4.0, + # Latest computer use requires Claude Opus 4.5+ (see also family restriction) + "computer-use-2025-11-24": 4.5, + # Context management requires Claude 4.5+ + "context-management-2025-06-27": 4.5, + # Effort parameter requires Claude 4.5+ (but only Opus 4.5, see family restrictions) + "effort-2025-11-24": 4.5, + # Tool search requires Claude 4.5+ + "tool-search-tool-2025-10-19": 4.5, + "tool-examples-2025-10-29": 4.5, +} + +# Model family restrictions for specific beta headers +# Only enforced if the version requirement is met +# Example: "effort-2025-11-24" requires Claude 4.5+ AND Opus family +BETA_HEADER_FAMILY_RESTRICTIONS: Dict[str, List[str]] = { + "computer-use-2025-11-24": ["opus"], # Only Opus 4.5+ supports latest computer use + "context-management-2025-06-27": ["sonnet", "haiku"], # Not supported on Opus 4.5 + "effort-2025-11-24": ["opus"], # Only Opus 4.5+ supports effort + # Tool search works on Opus 4.5+ and Sonnet 4.5+, but not Haiku + "tool-search-tool-2025-10-19": ["opus", "sonnet"], + "tool-examples-2025-10-29": ["opus", "sonnet"], +} + +# Beta headers that should be translated for backward compatibility +# Maps input header pattern to output headers +# Uses version-based approach for future-proofing +BETA_HEADER_TRANSLATIONS: Dict[str, Dict] = { + "advanced-tool-use": { + "target_headers": ["tool-search-tool-2025-10-19", "tool-examples-2025-10-29"], + "minimum_version": 4.5, # Requires Claude 4.5+ + "allowed_families": ["opus", "sonnet"], # Not available on Haiku + }, +} + + +class BedrockBetaHeaderFilter: + """ + Centralized filter for anthropic-beta headers across all Bedrock APIs. + + Uses a whitelist-based approach to ensure only supported headers are sent to AWS. + """ + + def __init__(self, api_type: BedrockAPI): + """ + Initialize the filter for a specific Bedrock API. + + Args: + api_type: The Bedrock API type (Invoke Chat, Invoke Messages, or Converse) + """ + self.api_type = api_type + self.supported_betas = self._get_supported_betas() + + def _get_supported_betas(self) -> Set[str]: + """Get the set of supported beta headers for this API type.""" + # Start with core supported headers + supported = BEDROCK_CORE_SUPPORTED_BETAS.copy() + + # Remove API-specific exclusions + exclusions = BEDROCK_API_EXCLUSIONS.get(self.api_type, set()) + supported -= exclusions + + return supported + + def _extract_model_version(self, model: str) -> Optional[float]: + """ + Extract Claude model version from Bedrock model ID. + + Args: + model: Bedrock model ID (e.g., "anthropic.claude-opus-4-5-20250514-v1:0") + + Returns: + Version as float (e.g., 4.5), or None if unable to parse + + Examples: + "anthropic.claude-opus-4-5-20250514-v1:0" -> 4.5 + "anthropic.claude-sonnet-4-20250514-v1:0" -> 4.0 + "anthropic.claude-3-5-sonnet-20240620-v1:0" -> 3.5 + "anthropic.claude-3-sonnet-20240229-v1:0" -> 3.0 + """ + match = re.search(MODEL_VERSION_PATTERN, model) + if not match: + return None + + major = int(match.group(1)) + minor = int(match.group(2)) if match.group(2) else 0 + + return float(f"{major}.{minor}") + + def _extract_model_family(self, model: str) -> Optional[str]: + """ + Extract Claude model family (opus, sonnet, haiku) from Bedrock model ID. + + Args: + model: Bedrock model ID + + Returns: + Family name (opus/sonnet/haiku) or None if unable to parse + + Examples: + "anthropic.claude-opus-4-5-20250514-v1:0" -> "opus" + "anthropic.claude-3-5-sonnet-20240620-v1:0" -> "sonnet" + """ + model_lower = model.lower() + if "opus" in model_lower: + return "opus" + elif "sonnet" in model_lower: + return "sonnet" + elif "haiku" in model_lower: + return "haiku" + return None + + def _model_supports_beta(self, model: str, beta: str) -> bool: + """ + Check if a model supports a specific beta header. + + Uses a future-proof approach: + 1. If beta has no version requirement -> ALLOW (supports all models) + 2. If beta has version requirement -> Extract model version and compare + 3. If beta has family restriction -> Check model family + + This means NEW models automatically support all beta headers unless explicitly + restricted by version/family requirements. + + Args: + model: The Bedrock model ID (e.g., "anthropic.claude-sonnet-4-20250514-v1:0") + beta: The beta header to check + + Returns: + True if the model supports the beta header, False otherwise + """ + # Default: If no version requirement specified, ALL Anthropic models support it + # This makes the system future-proof for new models + if beta not in BETA_HEADER_MINIMUM_VERSION: + return True + + # Extract model version + model_version = self._extract_model_version(model) + if model_version is None: + # If we can't parse version, be conservative and reject + # (This should rarely happen with well-formed Bedrock model IDs) + return False + + # Check minimum version requirement + required_version = BETA_HEADER_MINIMUM_VERSION[beta] + if model_version < required_version: + return False # Model version too old + + # Check family restrictions (if any) + if beta in BETA_HEADER_FAMILY_RESTRICTIONS: + model_family = self._extract_model_family(model) + if model_family is None: + # Can't determine family, be conservative + return False + + allowed_families = BETA_HEADER_FAMILY_RESTRICTIONS[beta] + if model_family not in allowed_families: + return False # Wrong family + + # All checks passed + return True + + def _translate_beta_headers(self, beta_headers: Set[str], model: str) -> Set[str]: + """ + Translate beta headers for backward compatibility. + + Uses version-based checks to determine if model supports translation. + Future-proof: new models at the required version automatically support translations. + + Args: + beta_headers: Set of beta headers to translate + model: The Bedrock model ID + + Returns: + Set of translated beta headers + """ + translated = beta_headers.copy() + + for input_pattern, translation_info in BETA_HEADER_TRANSLATIONS.items(): + # Check if any beta header matches the input pattern + matching_headers = [h for h in beta_headers if input_pattern in h.lower()] + + if matching_headers: + # Check if model supports the translation using version-based logic + model_version = self._extract_model_version(model) + if model_version is None: + continue # Can't determine version, skip translation + + # Check minimum version + required_version = translation_info.get("minimum_version") + if required_version and model_version < required_version: + continue # Model too old for this translation + + # Check family restrictions (if any) + allowed_families = translation_info.get("allowed_families") + if allowed_families: + model_family = self._extract_model_family(model) + if model_family not in allowed_families: + continue # Wrong family + + # Model supports translation - apply it + for header in matching_headers: + translated.discard(header) + + for target_header in translation_info["target_headers"]: + translated.add(target_header) + + return translated + + def filter_beta_headers( + self, beta_headers: List[str], model: str, translate: bool = True + ) -> List[str]: + """ + Filter and translate beta headers for Bedrock. + + This is the main entry point for filtering beta headers. + + Args: + beta_headers: List of beta headers from user request + model: The Bedrock model ID + translate: Whether to apply header translations (default: True) + + Returns: + Filtered and translated list of beta headers + """ + if not beta_headers: + return [] + + # Convert to set for efficient operations + beta_set = set(beta_headers) + + # Apply translations if enabled + if translate: + beta_set = self._translate_beta_headers(beta_set, model) + + # Filter: Keep only whitelisted headers that the model supports + filtered = { + beta + for beta in beta_set + if beta in self.supported_betas and self._model_supports_beta(model, beta) + } + + return sorted(list(filtered)) # Sort for deterministic output + + +def get_bedrock_beta_filter(api_type: BedrockAPI) -> BedrockBetaHeaderFilter: + """ + Factory function to get a beta header filter for a specific API. + + Args: + api_type: The Bedrock API type + + Returns: + BedrockBetaHeaderFilter instance + """ + return BedrockBetaHeaderFilter(api_type) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index dc3366bd5c6..db3cbde08a7 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -33,6 +33,10 @@ ) from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException +from litellm.llms.bedrock.beta_headers_config import ( + BedrockAPI, + get_bedrock_beta_filter, +) from litellm.types.llms.bedrock import * from litellm.types.llms.openai import ( AllMessageValues, @@ -84,13 +88,7 @@ "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 -] +# Beta header filtering is now handled by centralized beta_headers_config module # Models that support Bedrock's native structured outputs API (outputConfig.textFormat) # Uses substring matching against the Bedrock model ID @@ -1381,11 +1379,16 @@ def _process_tools_and_beta( # Append pre-formatted tools (systemTool etc.) after transformation bedrock_tools.extend(pre_formatted_tools) - # 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 + # Filter beta headers using centralized whitelist with model-specific support + # This handles version/family restrictions and unsupported beta patterns base_model = BedrockModelInfo.get_base_model(model) if anthropic_beta_list and base_model.startswith("anthropic"): - additional_request_params["anthropic_beta"] = anthropic_beta_list + beta_filter = get_bedrock_beta_filter(BedrockAPI.CONVERSE) + filtered_betas = beta_filter.filter_beta_headers( + anthropic_beta_list, model, translate=True + ) + 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 e1e4a318599..855e025b46c 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -10,6 +10,10 @@ get_anthropic_beta_from_headers, remove_custom_field_from_tools, ) +from litellm.llms.bedrock.beta_headers_config import ( + BedrockAPI, + get_bedrock_beta_filter, +) from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import ModelResponse @@ -142,13 +146,19 @@ def transform_request( 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 + + # Filter beta headers using centralized whitelist with model-specific support + # AWS Bedrock only supports a specific whitelist of beta flags + # Reference: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html + beta_filter = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + beta_list = beta_filter.filter_beta_headers( + list(beta_set), model, translate=False + ) + beta_set = set(beta_list) - # Filter out beta headers that Bedrock Invoke doesn't support - # Uses centralized configuration from anthropic_beta_headers_config.json - beta_list = list(beta_set) - _anthropic_request["anthropic_beta"] = beta_list + if beta_set: + _anthropic_request["anthropic_beta"] = list(beta_set) return _anthropic_request 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 18c65188d3d..107c11994df 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -23,6 +23,10 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import ( AmazonInvokeConfig, ) +from litellm.llms.bedrock.beta_headers_config import ( + BedrockAPI, + get_bedrock_beta_filter, +) from litellm.llms.bedrock.common_utils import ( get_anthropic_beta_from_headers, is_claude_4_5_on_bedrock, @@ -54,8 +58,7 @@ class AmazonAnthropicClaudeMessagesConfig( DEFAULT_BEDROCK_ANTHROPIC_API_VERSION = "bedrock-2023-05-31" - # Beta header patterns that are not supported by Bedrock Invoke API - # These will be filtered out to prevent 400 "invalid beta flag" errors + # Beta header filtering is now handled by centralized beta_headers_config module def __init__(self, **kwargs): BaseAnthropicMessagesConfig.__init__(self, **kwargs) @@ -209,25 +212,6 @@ def _supports_extended_thinking_on_bedrock(self, model: str) -> bool: return any(pattern in model_lower for pattern in supported_patterns) - def _is_claude_opus_4_5(self, model: str) -> bool: - """ - Check if the model is Claude Opus 4.5. - - Args: - model: The model name - - Returns: - True if the model is Claude Opus 4.5 - """ - model_lower = model.lower() - opus_4_5_patterns = [ - "opus-4.5", - "opus_4.5", - "opus-4-5", - "opus_4_5", - ] - return any(pattern in model_lower for pattern in opus_4_5_patterns) - def _is_claude_4_5_on_bedrock(self, model: str) -> bool: """ Check if the model is Claude 4.5 on Bedrock. @@ -242,49 +226,6 @@ def _is_claude_4_5_on_bedrock(self, model: str) -> bool: """ return is_claude_4_5_on_bedrock(model) - def _supports_tool_search_on_bedrock(self, model: str) -> bool: - """ - Check if the model supports tool search on Bedrock. - - On Amazon Bedrock, server-side tool search is supported on Claude Opus 4.5 - and Claude Sonnet 4.5 with the tool-search-tool-2025-10-19 beta header. - - Ref: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool - - Args: - model: The model name - - Returns: - True if the model supports tool search on Bedrock - """ - model_lower = model.lower() - - # Supported models for tool search on Bedrock - supported_patterns = [ - # Opus 4.5 - "opus-4.5", - "opus_4.5", - "opus-4-5", - "opus_4_5", - # Sonnet 4.5 - "sonnet-4.5", - "sonnet_4.5", - "sonnet-4-5", - "sonnet_4_5", - # Opus 4.6 - "opus-4.6", - "opus_4.6", - "opus-4-6", - "opus_4_6", - # sonnet 4.6 - "sonnet-4.6", - "sonnet_4.6", - "sonnet-4-6", - "sonnet_4_6", - ] - - return any(pattern in model_lower for pattern in supported_patterns) - def _get_tool_search_beta_header_for_bedrock( self, model: str, @@ -315,8 +256,9 @@ def _get_tool_search_beta_header_for_bedrock( 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") def _convert_output_format_to_inline_schema( self, @@ -463,11 +405,15 @@ def transform_anthropic_messages_request( beta_set=beta_set, ) - if "tool-search-tool-2025-10-19" in beta_set: - beta_set.add("tool-examples-2025-10-29") + # Filter beta headers using centralized whitelist with model-specific support and translation + # This handles advanced-tool-use translation and version/family restrictions + beta_filter = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + filtered_betas = beta_filter.filter_beta_headers( + list(beta_set), model, translate=True + ) - if beta_set: - anthropic_messages_request["anthropic_beta"] = list(beta_set) + if filtered_betas: + anthropic_messages_request["anthropic_beta"] = filtered_betas return anthropic_messages_request 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 074a319a603..897a3d6c056 100644 --- a/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py +++ b/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py @@ -51,15 +51,15 @@ 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"} - + result = config.transform_request( - model="anthropic.claude-3-5-sonnet-20241022-v2:0", + model="anthropic.claude-opus-4-5-20250514-v1:0", messages=[{"role": "user", "content": "Test"}], optional_params={}, litellm_params={}, headers=headers ) - + assert "anthropic_beta" in result # 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"} @@ -68,15 +68,15 @@ 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"} - + result = config._transform_request_helper( - model="anthropic.claude-3-5-sonnet-20241022-v2:0", + model="anthropic.claude-opus-4-5-20250514-v1:0", system_content_blocks=[], optional_params={}, messages=[{"role": "user", "content": "Test"}], headers=headers ) - + assert "additionalModelRequestFields" in result additional_fields = result["additionalModelRequestFields"] assert "anthropic_beta" in additional_fields @@ -104,7 +104,7 @@ def test_converse_computer_use_compatibility(self): """Test that user anthropic_beta headers work with computer use tools.""" config = AmazonConverseConfig() headers = {"anthropic-beta": "context-1m-2025-08-07"} - + # Computer use tools should automatically add computer-use-2024-10-22 tools = [ { @@ -114,28 +114,29 @@ def test_converse_computer_use_compatibility(self): "display_height_px": 768 } ] - + result = config._transform_request_helper( - model="anthropic.claude-3-5-sonnet-20241022-v2:0", + model="anthropic.claude-opus-4-5-20250514-v1:0", system_content_blocks=[], optional_params={"tools": tools}, messages=[{"role": "user", "content": "Test"}], headers=headers ) - + additional_fields = result["additionalModelRequestFields"] betas = additional_fields["anthropic_beta"] - + # 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 + # Opus 4.5 gets computer-use-2025-11-24 (not the older 2024-10-22) + assert "computer-use-2025-11-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.""" config = AmazonConverseConfig() headers = {} - + result = config._transform_request_helper( model="anthropic.claude-3-5-sonnet-20241022-v2:0", system_content_blocks=[], @@ -143,10 +144,323 @@ def test_no_anthropic_beta_headers(self): messages=[{"role": "user", "content": "Test"}], headers=headers ) - + additional_fields = result.get("additionalModelRequestFields", {}) assert "anthropic_beta" not in additional_fields + +class TestBedrockBetaHeaderFiltering: + """Test centralized beta header filtering across all Bedrock APIs.""" + + def test_invoke_chat_filters_unsupported_headers(self): + """Test that Invoke Chat API filters out unsupported beta headers.""" + config = AmazonAnthropicClaudeConfig() + headers = { + "anthropic-beta": "computer-use-2025-01-24,unknown-beta-2099-01-01,context-1m-2025-08-07" + } + + result = config.transform_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + + assert "anthropic_beta" in result + beta_set = set(result["anthropic_beta"]) + + # Should keep supported headers + assert "computer-use-2025-01-24" in beta_set + assert "context-1m-2025-08-07" in beta_set + + # Should filter out unsupported header + assert "unknown-beta-2099-01-01" not in beta_set + + def test_converse_filters_unsupported_headers(self): + """Test that Converse API filters out unsupported beta headers.""" + config = AmazonConverseConfig() + headers = { + "anthropic-beta": "interleaved-thinking-2025-05-14,unknown-beta-2099-01-01" + } + + result = config._transform_request_helper( + model="anthropic.claude-opus-4-5-20250514-v1:0", + system_content_blocks=[], + optional_params={}, + messages=[{"role": "user", "content": "Test"}], + headers=headers, + ) + + additional_fields = result["additionalModelRequestFields"] + beta_list = additional_fields["anthropic_beta"] + + # Should keep supported header + assert "interleaved-thinking-2025-05-14" in beta_list + + # Should filter out unsupported header + assert "unknown-beta-2099-01-01" not in beta_list + + def test_messages_filters_unsupported_headers(self): + """Test that Messages API filters out unsupported beta headers.""" + config = AmazonAnthropicClaudeMessagesConfig() + headers = { + "anthropic-beta": "output-128k-2025-02-19,unknown-beta-2099-01-01" + } + + result = config.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers, + ) + + assert "anthropic_beta" in result + beta_list = result["anthropic_beta"] + + # Should keep supported header + assert "output-128k-2025-02-19" in beta_list + + # Should filter out unsupported header + assert "unknown-beta-2099-01-01" not in beta_list + + def test_version_based_filtering_thinking_headers(self): + """Test that thinking headers are filtered based on model version.""" + config = AmazonAnthropicClaudeConfig() + headers = {"anthropic-beta": "interleaved-thinking-2025-05-14"} + + # Claude 4.5 should support thinking + result_45 = config.transform_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + assert "anthropic_beta" in result_45 + assert "interleaved-thinking-2025-05-14" in result_45["anthropic_beta"] + + # Claude 3.5 should NOT support thinking + result_35 = config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + # Should either not have anthropic_beta or not contain thinking header + if "anthropic_beta" in result_35: + assert "interleaved-thinking-2025-05-14" not in result_35["anthropic_beta"] + + def test_family_restriction_effort_opus_only(self): + """Test that effort parameter only works on Opus 4.5+.""" + config = AmazonAnthropicClaudeConfig() + headers = {"anthropic-beta": "effort-2025-11-24"} + + # Opus 4.5 should support effort + result_opus = config.transform_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + assert "anthropic_beta" in result_opus + assert "effort-2025-11-24" in result_opus["anthropic_beta"] + + # Sonnet 4.5 should NOT support effort (wrong family) + result_sonnet = config.transform_request( + model="anthropic.claude-sonnet-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + # Should either not have anthropic_beta or not contain effort + if "anthropic_beta" in result_sonnet: + assert "effort-2025-11-24" not in result_sonnet["anthropic_beta"] + + def test_tool_search_family_restriction(self): + """Test that tool search works on Opus and Sonnet 4.5+, but not Haiku.""" + config = AmazonAnthropicClaudeConfig() + headers = {"anthropic-beta": "tool-search-tool-2025-10-19"} + + # Opus 4.5 should support tool search + result_opus = config.transform_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + assert "tool-search-tool-2025-10-19" in result_opus["anthropic_beta"] + + # Sonnet 4.5 should support tool search + result_sonnet = config.transform_request( + model="anthropic.claude-sonnet-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + assert "tool-search-tool-2025-10-19" in result_sonnet["anthropic_beta"] + + # Haiku 4.5 should NOT support tool search (wrong family) + result_haiku = config.transform_request( + model="anthropic.claude-haiku-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + # Should either not have anthropic_beta or not contain tool search + if "anthropic_beta" in result_haiku: + assert "tool-search-tool-2025-10-19" not in result_haiku["anthropic_beta"] + + def test_messages_advanced_tool_use_translation(self): + """Test that Messages API translates advanced-tool-use to tool search headers.""" + config = AmazonAnthropicClaudeMessagesConfig() + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + # Opus 4.5 should translate advanced-tool-use + result = config.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers, + ) + + assert "anthropic_beta" in result + beta_list = result["anthropic_beta"] + + # Should translate to tool search headers + assert "tool-search-tool-2025-10-19" in beta_list + assert "tool-examples-2025-10-29" in beta_list + + # Should NOT contain original advanced-tool-use header + assert "advanced-tool-use-2025-11-20" not in beta_list + + def test_messages_advanced_tool_use_no_translation_old_model(self): + """Test that advanced-tool-use is NOT translated on older models.""" + config = AmazonAnthropicClaudeMessagesConfig() + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + # Claude 4.0 should NOT translate (too old) + result = config.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers, + ) + + # Should not have anthropic_beta or should be empty + # (advanced-tool-use is not in whitelist and shouldn't translate) + if "anthropic_beta" in result: + assert len(result["anthropic_beta"]) == 0 + + def test_messages_advanced_tool_use_no_translation_haiku(self): + """Test that advanced-tool-use is NOT translated on Haiku (wrong family).""" + config = AmazonAnthropicClaudeMessagesConfig() + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + # Haiku 4.5 should NOT translate (wrong family) + result = config.transform_anthropic_messages_request( + model="anthropic.claude-haiku-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers, + ) + + # Should not have anthropic_beta or should be empty + if "anthropic_beta" in result: + assert len(result["anthropic_beta"]) == 0 + + def test_cross_api_consistency(self): + """Test that same headers work consistently across all three APIs.""" + headers = {"anthropic-beta": "computer-use-2025-01-24,context-1m-2025-08-07"} + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + # Invoke Chat + config_invoke = AmazonAnthropicClaudeConfig() + result_invoke = config_invoke.transform_request( + model=model, + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + + # Converse + config_converse = AmazonConverseConfig() + result_converse = config_converse._transform_request_helper( + model=model, + system_content_blocks=[], + optional_params={}, + messages=[{"role": "user", "content": "Test"}], + headers=headers, + ) + + # Messages + config_messages = AmazonAnthropicClaudeMessagesConfig() + result_messages = config_messages.transform_anthropic_messages_request( + model=model, + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers, + ) + + # All should have the same beta headers + invoke_betas = set(result_invoke["anthropic_beta"]) + converse_betas = set( + result_converse["additionalModelRequestFields"]["anthropic_beta"] + ) + messages_betas = set(result_messages["anthropic_beta"]) + + assert invoke_betas == converse_betas == messages_betas + assert "computer-use-2025-01-24" in invoke_betas + assert "context-1m-2025-08-07" in invoke_betas + + def test_backward_compatibility_existing_headers(self): + """Test that all previously supported headers still work after migration.""" + config = AmazonAnthropicClaudeConfig() + + # Test all 11 core supported beta headers + all_headers = [ + "computer-use-2024-10-22", + "computer-use-2025-01-24", + "token-efficient-tools-2025-02-19", + "interleaved-thinking-2025-05-14", + "output-128k-2025-02-19", + "dev-full-thinking-2025-05-14", + "context-1m-2025-08-07", + "context-management-2025-06-27", + "effort-2025-11-24", + "tool-search-tool-2025-10-19", + "tool-examples-2025-10-29", + ] + + headers = {"anthropic-beta": ",".join(all_headers)} + + result = config.transform_request( + model="anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={}, + litellm_params={}, + headers=headers, + ) + + assert "anthropic_beta" in result + result_betas = set(result["anthropic_beta"]) + + # All headers should be present (Opus 4.5 supports all of them) + for header in all_headers: + assert header in result_betas, f"Header {header} was filtered out unexpectedly" + def test_anthropic_beta_all_supported_features(self): """Test that all documented beta features are properly handled.""" supported_features = [ @@ -158,18 +472,19 @@ def test_anthropic_beta_all_supported_features(self): "output-128k-2025-02-19", "dev-full-thinking-2025-05-14" ] - + config = AmazonAnthropicClaudeConfig() headers = {"anthropic-beta": ",".join(supported_features)} - + + # Use Claude 4.5+ model since several features require 4.0+ result = config.transform_request( - model="anthropic.claude-3-5-sonnet-20241022-v2:0", + model="anthropic.claude-opus-4-5-20250514-v1:0", messages=[{"role": "user", "content": "Test"}], optional_params={}, litellm_params={}, headers=headers ) - + assert "anthropic_beta" in result # Beta flags are stored as sets, so order may vary assert set(result["anthropic_beta"]) == set(supported_features) @@ -355,8 +670,8 @@ def test_converse_nova_model_no_anthropic_beta(self): def test_converse_anthropic_model_gets_anthropic_beta(self): """Test that Anthropic models DO get anthropic_beta in additionalModelRequestFields.""" config = AmazonConverseConfig() - headers = {"anthropic-beta": "context-1m-2025-08-07"} - + headers = {"anthropic-beta": "computer-use-2025-01-24"} + result = config._transform_request_helper( model="anthropic.claude-3-5-sonnet-20241022-v2:0", system_content_blocks=[], @@ -364,18 +679,18 @@ def test_converse_anthropic_model_gets_anthropic_beta(self): messages=[{"role": "user", "content": "Test"}], headers=headers ) - + additional_fields = result.get("additionalModelRequestFields", {}) assert "anthropic_beta" in additional_fields, ( "anthropic_beta SHOULD be added for Anthropic models." ) - assert "context-1m-2025-08-07" in additional_fields["anthropic_beta"] + assert "computer-use-2025-01-24" in additional_fields["anthropic_beta"] def test_converse_anthropic_model_with_cross_region_prefix(self): """Test that Anthropic models with cross-region prefix still get anthropic_beta.""" config = AmazonConverseConfig() - headers = {"anthropic-beta": "context-1m-2025-08-07"} - + headers = {"anthropic-beta": "computer-use-2025-01-24"} + # Model with 'us.' cross-region prefix result = config._transform_request_helper( model="us.anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -384,9 +699,174 @@ def test_converse_anthropic_model_with_cross_region_prefix(self): messages=[{"role": "user", "content": "Test"}], headers=headers ) - + additional_fields = result.get("additionalModelRequestFields", {}) assert "anthropic_beta" in additional_fields, ( "anthropic_beta SHOULD be added for Anthropic models with cross-region prefix." ) - assert "context-1m-2025-08-07" in additional_fields["anthropic_beta"] \ No newline at end of file + assert "computer-use-2025-01-24" in additional_fields["anthropic_beta"] + + def test_messages_advanced_tool_use_translation_opus_4_5(self): + """Test that advanced-tool-use header is translated to Bedrock-specific headers for Opus 4.5. + + Regression test for: Claude Code sends advanced-tool-use-2025-11-20 header which needs + to be translated to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 for + Bedrock Invoke API on Claude Opus 4.5. + + Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html + """ + config = AmazonAnthropicClaudeMessagesConfig() + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + result = config.transform_anthropic_messages_request( + model="us.anthropic.claude-opus-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers + ) + + assert "anthropic_beta" in result + beta_headers = result["anthropic_beta"] + + # advanced-tool-use should be removed + assert "advanced-tool-use-2025-11-20" not in beta_headers, ( + "advanced-tool-use-2025-11-20 should be removed for Bedrock Invoke API" + ) + + # Bedrock-specific headers should be added for Opus 4.5 + assert "tool-search-tool-2025-10-19" in beta_headers, ( + "tool-search-tool-2025-10-19 should be added for Opus 4.5" + ) + assert "tool-examples-2025-10-29" in beta_headers, ( + "tool-examples-2025-10-29 should be added for Opus 4.5" + ) + + def test_messages_advanced_tool_use_translation_sonnet_4_5(self): + """Test that advanced-tool-use header is translated to Bedrock-specific headers for Sonnet 4.5. + + Regression test for: Claude Code sends advanced-tool-use-2025-11-20 header which needs + to be translated to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 for + Bedrock Invoke API on Claude Sonnet 4.5. + + Ref: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool + """ + config = AmazonAnthropicClaudeMessagesConfig() + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + result = config.transform_anthropic_messages_request( + model="us.anthropic.claude-sonnet-4-5-20250929-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers + ) + + assert "anthropic_beta" in result + beta_headers = result["anthropic_beta"] + + # advanced-tool-use should be removed + assert "advanced-tool-use-2025-11-20" not in beta_headers, ( + "advanced-tool-use-2025-11-20 should be removed for Bedrock Invoke API" + ) + + # Bedrock-specific headers should be added for Sonnet 4.5 + assert "tool-search-tool-2025-10-19" in beta_headers, ( + "tool-search-tool-2025-10-19 should be added for Sonnet 4.5" + ) + assert "tool-examples-2025-10-29" in beta_headers, ( + "tool-examples-2025-10-29 should be added for Sonnet 4.5" + ) + + def test_messages_advanced_tool_use_filtered_unsupported_model(self): + """Test that advanced-tool-use header is filtered out for models that don't support tool search. + + The translation to Bedrock-specific headers should only happen for models that + support tool search on Bedrock (Opus 4.5, Sonnet 4.5). + For other models, the advanced-tool-use header should just be removed. + """ + config = AmazonAnthropicClaudeMessagesConfig() + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + # Test with Claude 3.5 Sonnet (does NOT support tool search on Bedrock) + result = config.transform_anthropic_messages_request( + model="us.anthropic.claude-3-5-sonnet-20241022-v2:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params={}, + headers=headers + ) + + beta_headers = result.get("anthropic_beta", []) + + # advanced-tool-use should be removed + assert "advanced-tool-use-2025-11-20" not in beta_headers + + # Bedrock-specific headers should NOT be added for unsupported models + assert "tool-search-tool-2025-10-19" not in beta_headers + assert "tool-examples-2025-10-29" not in beta_headers + + +class TestContextManagementBodyParamStripping: + """Test that context_management is stripped from request body for Bedrock APIs. + + Bedrock doesn't support context_management as a request body parameter. + The feature is enabled via the anthropic-beta header instead. If left in the body, + Bedrock returns: 'context_management: Extra inputs are not permitted'. + """ + + def test_messages_api_strips_context_management(self): + """Test that Messages API removes context_management from request body.""" + config = AmazonAnthropicClaudeMessagesConfig() + headers = {} + + result = config.transform_anthropic_messages_request( + model="anthropic.claude-sonnet-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + anthropic_messages_optional_request_params={ + "max_tokens": 100, + "context_management": {"type": "automatic", "max_context_tokens": 50000}, + }, + litellm_params={}, + headers=headers, + ) + + # context_management must NOT be in the request body + assert "context_management" not in result + + def test_invoke_chat_api_strips_context_management(self): + """Test that Invoke Chat API removes context_management from request body.""" + config = AmazonAnthropicClaudeConfig() + headers = {} + + result = config.transform_request( + model="anthropic.claude-sonnet-4-5-20250514-v1:0", + messages=[{"role": "user", "content": "Test"}], + optional_params={ + "context_management": {"type": "automatic", "max_context_tokens": 50000}, + }, + litellm_params={}, + headers=headers, + ) + + # context_management must NOT be in the request body + assert "context_management" not in result + + def test_converse_api_strips_context_management(self): + """Test that Converse API doesn't pass context_management in additionalModelRequestFields.""" + config = AmazonConverseConfig() + headers = {} + + result = config._transform_request_helper( + model="anthropic.claude-sonnet-4-5-20250514-v1:0", + system_content_blocks=[], + optional_params={ + "context_management": {"type": "automatic", "max_context_tokens": 50000}, + }, + messages=[{"role": "user", "content": "Test"}], + headers=headers, + ) + + additional_fields = result.get("additionalModelRequestFields", {}) + # context_management must NOT leak into additionalModelRequestFields + assert "context_management" not in additional_fields diff --git a/tests/test_litellm/llms/bedrock/test_beta_headers_config.py b/tests/test_litellm/llms/bedrock/test_beta_headers_config.py new file mode 100644 index 00000000000..cb365ef5165 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/test_beta_headers_config.py @@ -0,0 +1,546 @@ +""" +Comprehensive tests for Bedrock beta headers configuration. + +Tests the centralized whitelist-based filtering with version-based model support. +""" + +import pytest + +from litellm.llms.bedrock.beta_headers_config import ( + BEDROCK_CORE_SUPPORTED_BETAS, + BedrockAPI, + BedrockBetaHeaderFilter, + get_bedrock_beta_filter, +) + + +class TestBedrockBetaHeaderFilter: + """Test the BedrockBetaHeaderFilter class.""" + + def test_factory_function(self): + """Test factory function returns correct filter instance.""" + filter_chat = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + assert isinstance(filter_chat, BedrockBetaHeaderFilter) + assert filter_chat.api_type == BedrockAPI.INVOKE_CHAT + + filter_converse = get_bedrock_beta_filter(BedrockAPI.CONVERSE) + assert isinstance(filter_converse, BedrockBetaHeaderFilter) + assert filter_converse.api_type == BedrockAPI.CONVERSE + + def test_whitelist_filtering_basic(self): + """Test basic whitelist filtering keeps supported headers.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + # Supported header should pass through + result = filter_obj.filter_beta_headers( + ["computer-use-2025-01-24"], model, translate=False + ) + assert result == ["computer-use-2025-01-24"] + + def test_whitelist_filtering_blocks_unsupported(self): + """Test whitelist filtering blocks unsupported headers.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + # Unsupported header should be filtered out + result = filter_obj.filter_beta_headers( + ["unknown-beta-2099-01-01"], model, translate=False + ) + assert result == [] + + def test_whitelist_filtering_mixed_headers(self): + """Test filtering with mix of supported and unsupported headers.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + [ + "computer-use-2025-01-24", # Supported + "unknown-beta-2099-01-01", # Unsupported + "effort-2025-11-24", # Supported + ], + model, + translate=False, + ) + # Should only keep supported headers + assert set(result) == {"computer-use-2025-01-24", "effort-2025-11-24"} + + def test_empty_headers_list(self): + """Test filtering with empty headers list.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers([], model) + assert result == [] + + def test_all_supported_betas_in_whitelist(self): + """Test that all core supported betas are in whitelist.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + assert len(filter_obj.supported_betas) == len(BEDROCK_CORE_SUPPORTED_BETAS) + + +class TestModelVersionExtraction: + """Test model version extraction logic.""" + + def test_extract_version_opus_4_5(self): + """Test version extraction for Claude Opus 4.5.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version( + "anthropic.claude-opus-4-5-20250514-v1:0" + ) + assert version == 4.5 + + def test_extract_version_sonnet_4(self): + """Test version extraction for Claude Sonnet 4.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version( + "anthropic.claude-sonnet-4-20250514-v1:0" + ) + assert version == 4.0 + + def test_extract_version_legacy_3_5_sonnet(self): + """Test version extraction for legacy Claude 3.5 Sonnet.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version( + "anthropic.claude-3-5-sonnet-20240620-v1:0" + ) + assert version == 3.5 + + def test_extract_version_legacy_3_sonnet(self): + """Test version extraction for legacy Claude 3 Sonnet.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version( + "anthropic.claude-3-sonnet-20240229-v1:0" + ) + assert version == 3.0 + + def test_extract_version_haiku_4_5(self): + """Test version extraction for Claude Haiku 4.5.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version( + "anthropic.claude-haiku-4-5-20250514-v1:0" + ) + assert version == 4.5 + + def test_extract_version_invalid_format(self): + """Test version extraction with invalid model format.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version("invalid-model-format") + assert version is None + + def test_extract_version_future_opus_5(self): + """Test version extraction for future Claude Opus 5.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + version = filter_obj._extract_model_version( + "anthropic.claude-opus-5-20270101-v1:0" + ) + assert version == 5.0 + + def test_extract_version_suffix_less_model_id(self): + """Test version extraction for suffix-less model IDs (no date/v1 suffix).""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + # These bare IDs are valid Bedrock model identifiers + assert filter_obj._extract_model_version("anthropic.claude-sonnet-4-6") == 4.6 + assert filter_obj._extract_model_version("anthropic.claude-opus-4-5") == 4.5 + assert filter_obj._extract_model_version("anthropic.claude-haiku-4-5") == 4.5 + + +class TestModelFamilyExtraction: + """Test model family extraction logic.""" + + def test_extract_family_opus(self): + """Test family extraction for Opus models.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + family = filter_obj._extract_model_family( + "anthropic.claude-opus-4-5-20250514-v1:0" + ) + assert family == "opus" + + def test_extract_family_sonnet(self): + """Test family extraction for Sonnet models.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + family = filter_obj._extract_model_family( + "anthropic.claude-sonnet-4-20250514-v1:0" + ) + assert family == "sonnet" + + def test_extract_family_haiku(self): + """Test family extraction for Haiku models.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + family = filter_obj._extract_model_family( + "anthropic.claude-haiku-4-5-20250514-v1:0" + ) + assert family == "haiku" + + def test_extract_family_legacy_sonnet(self): + """Test family extraction for legacy Sonnet naming.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + family = filter_obj._extract_model_family( + "anthropic.claude-3-5-sonnet-20240620-v1:0" + ) + assert family == "sonnet" + + def test_extract_family_invalid(self): + """Test family extraction with invalid model format.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + family = filter_obj._extract_model_family("invalid-model-format") + assert family is None + + +class TestVersionBasedFiltering: + """Test version-based filtering for beta headers.""" + + def test_thinking_headers_require_claude_4(self): + """Test that thinking headers require Claude 4.0+.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Claude 4.5 should support thinking + result = filter_obj.filter_beta_headers( + ["interleaved-thinking-2025-05-14"], + "anthropic.claude-opus-4-5-20250514-v1:0", + translate=False, + ) + assert "interleaved-thinking-2025-05-14" in result + + # Claude 3.5 should NOT support thinking + result = filter_obj.filter_beta_headers( + ["interleaved-thinking-2025-05-14"], + "anthropic.claude-3-5-sonnet-20240620-v1:0", + translate=False, + ) + assert "interleaved-thinking-2025-05-14" not in result + + def test_context_management_requires_claude_4_5(self): + """Test that context management requires Claude 4.5+.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Claude 4.5 should support context management + result = filter_obj.filter_beta_headers( + ["context-management-2025-06-27"], + "anthropic.claude-sonnet-4-5-20250514-v1:0", + translate=False, + ) + assert "context-management-2025-06-27" in result + + # Claude 4.0 should NOT support context management + result = filter_obj.filter_beta_headers( + ["context-management-2025-06-27"], + "anthropic.claude-sonnet-4-20250514-v1:0", + translate=False, + ) + assert "context-management-2025-06-27" not in result + + # Haiku 4.5 should support context management + result = filter_obj.filter_beta_headers( + ["context-management-2025-06-27"], + "anthropic.claude-haiku-4-5-20250514-v1:0", + translate=False, + ) + assert "context-management-2025-06-27" in result + + # Opus 4.5 should NOT support context management (wrong family) + result = filter_obj.filter_beta_headers( + ["context-management-2025-06-27"], + "anthropic.claude-opus-4-5-20250514-v1:0", + translate=False, + ) + assert "context-management-2025-06-27" not in result + + def test_computer_use_works_on_all_versions(self): + """Test that computer-use works on all Claude versions (no version requirement).""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Claude 3.5 should support computer use + result = filter_obj.filter_beta_headers( + ["computer-use-2025-01-24"], + "anthropic.claude-3-5-sonnet-20240620-v1:0", + translate=False, + ) + assert "computer-use-2025-01-24" in result + + # Claude 4.5 should also support computer use + result = filter_obj.filter_beta_headers( + ["computer-use-2025-01-24"], + "anthropic.claude-opus-4-5-20250514-v1:0", + translate=False, + ) + assert "computer-use-2025-01-24" in result + + def test_future_model_supports_all_headers(self): + """Test that future Claude 5.0 automatically supports all 4.0+ headers.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Opus 5 should support thinking/context headers (no family restriction on those) + result = filter_obj.filter_beta_headers( + [ + "interleaved-thinking-2025-05-14", # Requires 4.0+ + "context-1m-2025-08-07", # Requires 4.0+ + ], + "anthropic.claude-opus-5-20270101-v1:0", + translate=False, + ) + assert "interleaved-thinking-2025-05-14" in result + assert "context-1m-2025-08-07" in result + + # Sonnet 5 should support context-management (sonnet/haiku family only) + result = filter_obj.filter_beta_headers( + ["context-management-2025-06-27"], # Requires 4.5+, sonnet/haiku only + "anthropic.claude-sonnet-5-20270101-v1:0", + translate=False, + ) + assert "context-management-2025-06-27" in result + + +class TestFamilyRestrictions: + """Test model family restrictions for beta headers.""" + + def test_effort_only_on_opus(self): + """Test that effort parameter only works on Opus 4.5+.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Opus 4.5 should support effort + result = filter_obj.filter_beta_headers( + ["effort-2025-11-24"], + "anthropic.claude-opus-4-5-20250514-v1:0", + translate=False, + ) + assert "effort-2025-11-24" in result + + # Sonnet 4.5 should NOT support effort (wrong family) + result = filter_obj.filter_beta_headers( + ["effort-2025-11-24"], + "anthropic.claude-sonnet-4-5-20250514-v1:0", + translate=False, + ) + assert "effort-2025-11-24" not in result + + # Haiku 4.5 should NOT support effort (wrong family) + result = filter_obj.filter_beta_headers( + ["effort-2025-11-24"], + "anthropic.claude-haiku-4-5-20250514-v1:0", + translate=False, + ) + assert "effort-2025-11-24" not in result + + def test_tool_search_on_opus_and_sonnet(self): + """Test that tool search works on Opus and Sonnet 4.5+, but not Haiku.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Opus 4.5 should support tool search + result = filter_obj.filter_beta_headers( + ["tool-search-tool-2025-10-19"], + "anthropic.claude-opus-4-5-20250514-v1:0", + translate=False, + ) + assert "tool-search-tool-2025-10-19" in result + + # Sonnet 4.5 should support tool search + result = filter_obj.filter_beta_headers( + ["tool-search-tool-2025-10-19"], + "anthropic.claude-sonnet-4-5-20250514-v1:0", + translate=False, + ) + assert "tool-search-tool-2025-10-19" in result + + # Haiku 4.5 should NOT support tool search (wrong family) + result = filter_obj.filter_beta_headers( + ["tool-search-tool-2025-10-19"], + "anthropic.claude-haiku-4-5-20250514-v1:0", + translate=False, + ) + assert "tool-search-tool-2025-10-19" not in result + + def test_latest_computer_use_version_and_family_restriction(self): + """Test that computer-use-2025-11-24 requires Opus 4.5+.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + + # Opus 4.5 should support latest computer use + result = filter_obj.filter_beta_headers( + ["computer-use-2025-11-24"], + "anthropic.claude-opus-4-5-20250514-v1:0", + translate=False, + ) + assert "computer-use-2025-11-24" in result + + # Sonnet 4.5 should NOT support latest computer use (wrong family) + result = filter_obj.filter_beta_headers( + ["computer-use-2025-11-24"], + "anthropic.claude-sonnet-4-5-20250514-v1:0", + translate=False, + ) + assert "computer-use-2025-11-24" not in result + + # Opus 3.7 should NOT support latest computer use (version too old) + result = filter_obj.filter_beta_headers( + ["computer-use-2025-11-24"], + "anthropic.claude-3-7-sonnet-20250219-v1:0", + translate=False, + ) + assert "computer-use-2025-11-24" not in result + + # Claude 3.5 Sonnet should NOT support latest computer use (both version and family) + result = filter_obj.filter_beta_headers( + ["computer-use-2025-11-24"], + "anthropic.claude-3-5-sonnet-20241022-v2:0", + translate=False, + ) + assert "computer-use-2025-11-24" not in result + + +class TestBetaHeaderTranslation: + """Test beta header translation for backward compatibility.""" + + def test_advanced_tool_use_translation_opus_4_5(self): + """Test advanced-tool-use translates to tool search headers on Opus 4.5.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + ["advanced-tool-use-2025-11-20"], model, translate=True + ) + + # Should translate to tool search headers + assert "tool-search-tool-2025-10-19" in result + assert "tool-examples-2025-10-29" in result + assert "advanced-tool-use-2025-11-20" not in result + + def test_advanced_tool_use_translation_sonnet_4_5(self): + """Test advanced-tool-use translates on Sonnet 4.5.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + model = "anthropic.claude-sonnet-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + ["advanced-tool-use-2025-11-20"], model, translate=True + ) + + # Should translate to tool search headers + assert "tool-search-tool-2025-10-19" in result + assert "tool-examples-2025-10-29" in result + + def test_advanced_tool_use_no_translation_claude_4(self): + """Test advanced-tool-use does NOT translate on Claude 4.0 (too old).""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + model = "anthropic.claude-opus-4-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + ["advanced-tool-use-2025-11-20"], model, translate=True + ) + + # Should not translate (version too old) + assert result == [] + + def test_advanced_tool_use_no_translation_haiku(self): + """Test advanced-tool-use does NOT translate on Haiku (wrong family).""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + model = "anthropic.claude-haiku-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + ["advanced-tool-use-2025-11-20"], model, translate=True + ) + + # Should not translate (wrong family) + assert result == [] + + def test_translation_disabled(self): + """Test that translation can be disabled.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + ["advanced-tool-use-2025-11-20"], model, translate=False + ) + + # Should not translate when disabled + # advanced-tool-use is not in whitelist, so should be filtered out + assert result == [] + + +class TestCrossAPIConsistency: + """Test that filtering is consistent across all three APIs.""" + + def test_same_headers_work_on_all_apis(self): + """Test that supported headers work consistently across all APIs.""" + model = "anthropic.claude-opus-4-5-20250514-v1:0" + headers = ["computer-use-2025-01-24", "effort-2025-11-24"] + + filter_chat = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + filter_messages = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + filter_converse = get_bedrock_beta_filter(BedrockAPI.CONVERSE) + + result_chat = set(filter_chat.filter_beta_headers(headers, model, translate=False)) + result_messages = set( + filter_messages.filter_beta_headers(headers, model, translate=False) + ) + result_converse = set( + filter_converse.filter_beta_headers(headers, model, translate=False) + ) + + # All APIs should return the same results + assert result_chat == result_messages == result_converse + + def test_unsupported_headers_filtered_on_all_apis(self): + """Test that unsupported headers are filtered consistently.""" + model = "anthropic.claude-opus-4-5-20250514-v1:0" + headers = ["unknown-beta-2099-01-01"] + + filter_chat = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + filter_messages = get_bedrock_beta_filter(BedrockAPI.INVOKE_MESSAGES) + filter_converse = get_bedrock_beta_filter(BedrockAPI.CONVERSE) + + result_chat = filter_chat.filter_beta_headers(headers, model) + result_messages = filter_messages.filter_beta_headers(headers, model) + result_converse = filter_converse.filter_beta_headers(headers, model) + + # All should filter out unsupported headers + assert result_chat == result_messages == result_converse == [] + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_none_model_version_blocks_versioned_headers(self): + """Test that unparseable model version blocks headers with version requirements.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "invalid-model-format" + + # Headers with version requirements should be blocked + result = filter_obj.filter_beta_headers( + ["interleaved-thinking-2025-05-14"], model, translate=False + ) + assert result == [] + + # Headers without version requirements should still work + result = filter_obj.filter_beta_headers( + ["computer-use-2025-01-24"], model, translate=False + ) + assert "computer-use-2025-01-24" in result + + def test_duplicate_headers_deduplicated(self): + """Test that duplicate headers are deduplicated.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + [ + "computer-use-2025-01-24", + "computer-use-2025-01-24", + "computer-use-2025-01-24", + ], + model, + translate=False, + ) + assert result == ["computer-use-2025-01-24"] + + def test_output_is_sorted(self): + """Test that output is sorted for deterministic results.""" + filter_obj = get_bedrock_beta_filter(BedrockAPI.INVOKE_CHAT) + model = "anthropic.claude-opus-4-5-20250514-v1:0" + + result = filter_obj.filter_beta_headers( + ["effort-2025-11-24", "computer-use-2025-01-24", "context-1m-2025-08-07"], + model, + translate=False, + ) + # Should be alphabetically sorted + assert result == sorted(result) From bdf1260aaa1af83bcd23ce5e639b146c89997abc Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Fri, 13 Mar 2026 02:02:55 -0400 Subject: [PATCH 7/7] fix(websearch_interception): load API keys from router configuration Fixes issue where websearch interception failed with "TAVILY_API_KEY is not set" error when using search providers that require API keys configured in the proxy config rather than environment variables. Extract api_key and api_base from the router search_tools litellm_params configuration and pass them to litellm.asearch(). Falls back to environment variables when credentials are not in the config. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../websearch_interception/handler.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 2541a0bd7aa..ec6c58e047f 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -697,8 +697,10 @@ async def _execute_search(self, query: str) -> str: ) llm_router = None - # Determine search provider from router's search_tools + # Determine search provider and credentials from router's search_tools search_provider: Optional[str] = None + api_key: Optional[str] = None + api_base: Optional[str] = None if llm_router is not None and hasattr(llm_router, "search_tools"): if self.search_tool_name: # Find specific search tool by name @@ -709,9 +711,10 @@ async def _execute_search(self, query: str) -> str: ] if matching_tools: search_tool = matching_tools[0] - search_provider = search_tool.get("litellm_params", {}).get( - "search_provider" - ) + litellm_params = search_tool.get("litellm_params", {}) + search_provider = litellm_params.get("search_provider") + api_key = litellm_params.get("api_key") + api_base = litellm_params.get("api_base") verbose_logger.debug( f"WebSearchInterception: Found search tool '{self.search_tool_name}' " f"with provider '{search_provider}'" @@ -725,9 +728,10 @@ async def _execute_search(self, query: str) -> str: # If no specific tool or not found, use first available if not search_provider and llm_router.search_tools: first_tool = llm_router.search_tools[0] - search_provider = first_tool.get("litellm_params", {}).get( - "search_provider" - ) + litellm_params = first_tool.get("litellm_params", {}) + search_provider = litellm_params.get("search_provider") + api_key = litellm_params.get("api_key") + api_base = litellm_params.get("api_base") verbose_logger.debug( f"WebSearchInterception: Using first available search tool with provider '{search_provider}'" ) @@ -743,7 +747,15 @@ async def _execute_search(self, query: str) -> str: verbose_logger.debug( f"WebSearchInterception: Executing search for '{query}' using provider '{search_provider}'" ) - result = await litellm.asearch(query=query, search_provider=search_provider) + search_kwargs: Dict[str, Any] = { + "query": query, + "search_provider": search_provider, + } + if api_key: + search_kwargs["api_key"] = api_key + if api_base: + search_kwargs["api_base"] = api_base + result = await litellm.asearch(**search_kwargs) # Format using transformation function search_result_text = WebSearchTransformation.format_search_response(result)