fix(bedrock): sanitize tool_use IDs in pass-through messages

Bedrock requires tool_use IDs to match ^[a-zA-Z0-9_-]+$ but the
Anthropic native API allows broader characters. When clients like
Claude Code send requests through the /v1/messages pass-through
endpoint, IDs containing dots, plus signs, or other special characters
cause Bedrock to return 400 Bad Request errors.

Added _sanitize_tool_use_ids() to AmazonAnthropicClaudeMessagesConfig
that replaces invalid characters with underscores in both tool_use.id
and tool_result.tool_use_id fields during Bedrock-specific request
transformation.

Fixes #21114

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Hunter 2026-03-20 13:34:49 +01:00
parent d7c419bfee
commit 28c2873dae
2 changed files with 186 additions and 0 deletions

View file

@ -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")

View file

@ -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"