diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 4f15d1b3cef..e03ead8a983 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -246,6 +246,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): metadata: Optional[dict] = None system: Optional[str] = None + # Bedrock rejects `x-anthropic-billing-header:` text blocks in the system + # array as reserved-keyword (see #20951), so its subclasses set this to True + # to drop them on the wire. Anthropic-direct (this base class) must leave + # them in place: Claude Code uses them as the recognition signal for + # Max/Pro OAuth tokens, and stripping them causes 429s on Anthropic (#29572). + _strips_x_anthropic_billing_header: bool = False + def __init__( self, max_tokens: Optional[int] = None, @@ -1639,10 +1646,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # Skip empty text blocks - Anthropic API raises errors for empty text if not system_message_block["content"]: continue - # Skip system messages containing x-anthropic-billing-header metadata - if system_message_block["content"].startswith( - "x-anthropic-billing-header:" - ): + # Skip system messages containing x-anthropic-billing-header + # metadata only for targets that reject it (e.g. Bedrock). + # Anthropic-direct keeps the header so Claude Code's + # Max/Pro OAuth recognition signal isn't dropped (#29572). + if self._strips_x_anthropic_billing_header and system_message_block[ + "content" + ].startswith("x-anthropic-billing-header:"): continue anthropic_system_message_content = AnthropicSystemMessageContent( type="text", @@ -1661,9 +1671,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): text_value = _content.get("text") if _content.get("type") == "text" and not text_value: continue - # Skip system messages containing x-anthropic-billing-header metadata + # Skip x-anthropic-billing-header text blocks only for + # targets that reject the header (#29572). if ( - _content.get("type") == "text" + self._strips_x_anthropic_billing_header + and _content.get("type") == "text" and text_value and text_value.startswith("x-anthropic-billing-header:") ): @@ -2449,11 +2461,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): tool_calls, ) - json_mode_message, tool_calls_for_message, json_extra_content = ( - self._resolve_json_mode_non_streaming( - json_mode=json_mode, - tool_calls=tool_calls, - ) + ( + json_mode_message, + tool_calls_for_message, + json_extra_content, + ) = self._resolve_json_mode_non_streaming( + json_mode=json_mode, + tool_calls=tool_calls, ) merged_text = text_content or "" if json_extra_content: diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index a13336b6c88..a1cfa556a87 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -56,6 +56,10 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): anthropic_version: str = "bedrock-2023-05-31" + # Bedrock rejects `x-anthropic-billing-header:` text blocks in the system + # array as a reserved keyword (#20951), so strip them on the wire. + _strips_x_anthropic_billing_header: bool = True + @property def custom_llm_provider(self) -> Optional[str]: return "bedrock" diff --git a/litellm/llms/bedrock/claude_platform/transformation.py b/litellm/llms/bedrock/claude_platform/transformation.py index 0167c457c96..296e8d6a120 100644 --- a/litellm/llms/bedrock/claude_platform/transformation.py +++ b/litellm/llms/bedrock/claude_platform/transformation.py @@ -13,6 +13,10 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig): Bedrock Claude Platform uses Anthropic's Messages API with AWS gateway auth. """ + # Bedrock rejects `x-anthropic-billing-header:` text blocks in the system + # array as a reserved keyword (#20951), so strip them on the wire. + _strips_x_anthropic_billing_header: bool = True + @property def custom_llm_provider(self) -> Optional[str]: return "bedrock" diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index d501ae0f79a..7de06ed5c97 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -5090,3 +5090,86 @@ def test_map_tool_helper_collision_prefers_definitions_over_components_schemas() # Cross-namespace ref *also* resolves to the `definitions` body because # ``unpack_defs`` keys by last path segment -- documented limitation. assert transformed["input_schema"]["properties"]["from_components"] == expected + + +def test_translate_system_message_preserves_x_anthropic_billing_header_on_anthropic_direct(): + """Regression for #29572. + + Anthropic-direct must preserve `x-anthropic-billing-header:` text blocks + in the system array: Claude Code uses them as the recognition signal for + Max/Pro OAuth tokens, and stripping them causes 429s on Anthropic. + Only Bedrock subclasses strip the header. + """ + config = AnthropicConfig() + + # String content path + messages_str = [ + {"role": "system", "content": "x-anthropic-billing-header: claude-code"}, + {"role": "user", "content": "hi"}, + ] + result = config.translate_system_message(messages_str) + assert len(result) == 1 + assert result[0]["text"] == "x-anthropic-billing-header: claude-code" + + # List content path + messages_list = [ + { + "role": "system", + "content": [ + {"type": "text", "text": "x-anthropic-billing-header: claude-code"}, + { + "type": "text", + "text": "You are a security monitor for autonomous AI coding agents.", + }, + ], + }, + {"role": "user", "content": "hi"}, + ] + result = config.translate_system_message(messages_list) + assert [block["text"] for block in result] == [ + "x-anthropic-billing-header: claude-code", + "You are a security monitor for autonomous AI coding agents.", + ] + + +def test_translate_system_message_strips_x_anthropic_billing_header_on_bedrock_invoke(): + """Bedrock (AmazonAnthropicClaudeConfig) must strip `x-anthropic-billing-header:` + blocks because Bedrock rejects them as a reserved keyword (#20951). + Verifies the per-subclass `_strips_x_anthropic_billing_header` flag wired + up for #29572 doesn't regress that behavior. + """ + from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( + AmazonAnthropicClaudeConfig, + ) + + config = AmazonAnthropicClaudeConfig() + + messages = [ + { + "role": "system", + "content": [ + {"type": "text", "text": "x-anthropic-billing-header: claude-code"}, + {"type": "text", "text": "You are a helpful assistant."}, + ], + }, + {"role": "user", "content": "hi"}, + ] + result = config.translate_system_message(messages) + assert [block["text"] for block in result] == ["You are a helpful assistant."] + + +def test_translate_system_message_strips_x_anthropic_billing_header_on_bedrock_claude_platform(): + """Same as above for BedrockClaudePlatformConfig.""" + from litellm.llms.bedrock.claude_platform.transformation import ( + BedrockClaudePlatformConfig, + ) + + config = BedrockClaudePlatformConfig() + + messages = [ + {"role": "system", "content": "x-anthropic-billing-header: claude-code"}, + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "hi"}, + ] + result = config.translate_system_message(messages) + assert [block["text"] for block in result] == ["You are a helpful assistant."]