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..5ea6cd38553 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -1,3 +1,4 @@ +import re from typing import ( TYPE_CHECKING, Any, @@ -372,6 +373,49 @@ class AmazonAnthropicClaudeMessagesConfig( schema_text = {"type": "text", "text": json.dumps(schema)} content.append(schema_text) + + _VALID_TOOL_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + _INVALID_TOOL_ID_CHARS = re.compile(r"[^a-zA-Z0-9_-]") + + def _sanitize_tool_use_ids( + self, anthropic_messages_request: Dict + ) -> None: + """ + Sanitize tool_use IDs to match Bedrock's required pattern. + + Bedrock requires tool_use IDs to match ``^[a-zA-Z0-9_-]+$`` but the + Anthropic native API allows broader characters. Clients like Claude Code + send requests through the pass-through endpoint with IDs that Bedrock + rejects with 400 Bad Request. + + Replaces any invalid characters with underscores in both ``tool_use.id`` + and ``tool_result.tool_use_id`` fields. + + Fixes: https://github.com/BerriAI/litellm/issues/21114 + """ + messages = anthropic_messages_request.get("messages") + if not isinstance(messages, list): + return + + for message in messages: + if not isinstance(message, dict): + continue + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "tool_use" and "id" in block: + tool_id = block["id"] + if isinstance(tool_id, str) and not self._VALID_TOOL_ID_PATTERN.match(tool_id): + block["id"] = self._INVALID_TOOL_ID_CHARS.sub("_", tool_id) + elif block_type == "tool_result" and "tool_use_id" in block: + tool_use_id = block["tool_use_id"] + if isinstance(tool_use_id, str) and not self._VALID_TOOL_ID_PATTERN.match(tool_use_id): + block["tool_use_id"] = self._INVALID_TOOL_ID_CHARS.sub("_", tool_use_id) + def transform_anthropic_messages_request( self, model: str, @@ -429,6 +473,12 @@ class AmazonAnthropicClaudeMessagesConfig( # Ref: https://github.com/BerriAI/litellm/issues/22847 remove_custom_field_from_tools(anthropic_messages_request) + # 7. Sanitize tool_use IDs (Bedrock requires ^[a-zA-Z0-9_-]+$) + # The Anthropic native API allows broader characters in tool_use IDs, + # but Bedrock rejects them with 400 Bad Request. + # Fixes: https://github.com/BerriAI/litellm/issues/21114 + self._sanitize_tool_use_ids(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/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index f69f478278f..38e9d7c56d9 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -342,3 +342,139 @@ def test_bedrock_messages_strips_output_config_with_output_format(): assert "output_config" not in result assert "output_format" not in result + + +class TestSanitizeToolUseIds: + """Tests for _sanitize_tool_use_ids in AmazonAnthropicClaudeMessagesConfig. + + Bedrock requires tool_use IDs to match ^[a-zA-Z0-9_-]+$ but the Anthropic + native API allows broader characters. + Fixes: https://github.com/BerriAI/litellm/issues/21114 + """ + + def test_sanitize_tool_use_id_with_invalid_chars(self): + """tool_use.id with invalid characters should be sanitized.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_abc.123+xyz/foo", + "name": "test_tool", + "input": {}, + } + ], + } + ] + } + cfg._sanitize_tool_use_ids(request) + assert request["messages"][0]["content"][0]["id"] == "toolu_abc_123_xyz_foo" + + def test_sanitize_tool_result_id_with_invalid_chars(self): + """tool_result.tool_use_id with invalid characters should be sanitized.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_abc.123+xyz/foo", + "content": "result", + } + ], + } + ] + } + cfg._sanitize_tool_use_ids(request) + assert ( + request["messages"][0]["content"][0]["tool_use_id"] + == "toolu_abc_123_xyz_foo" + ) + + def test_valid_ids_unchanged(self): + """IDs that already match the Bedrock pattern should not be modified.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_valid-id_123", + "name": "test_tool", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_valid-id_123", + "content": "result", + } + ], + }, + ] + } + cfg._sanitize_tool_use_ids(request) + assert request["messages"][0]["content"][0]["id"] == "toolu_valid-id_123" + assert ( + request["messages"][1]["content"][0]["tool_use_id"] == "toolu_valid-id_123" + ) + + def test_no_messages_key(self): + """Should handle request without messages key gracefully.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = {"max_tokens": 1024} + cfg._sanitize_tool_use_ids(request) # Should not raise + + def test_string_content_ignored(self): + """Messages with string content (not list) should be skipped.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "messages": [{"role": "user", "content": "hello"}] + } + cfg._sanitize_tool_use_ids(request) # Should not raise + + def test_consistent_sanitization_across_pairs(self): + """tool_use.id and matching tool_result.tool_use_id should sanitize identically.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + original_id = "toolu_01A.B+C/D:E" + request = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": original_id, + "name": "test", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": original_id, + "content": "result", + } + ], + }, + ] + } + cfg._sanitize_tool_use_ids(request) + sanitized_tool_use = request["messages"][0]["content"][0]["id"] + sanitized_tool_result = request["messages"][1]["content"][0]["tool_use_id"] + assert sanitized_tool_use == sanitized_tool_result + assert sanitized_tool_use == "toolu_01A_B_C_D_E"