Merge pull request #40872 from BerriAI/litellm_bedrock_sanitize_tool_use_id

fix(bedrock): sanitize client tool_call ids to Bedrock toolUseId constraints
This commit is contained in:
Mateo Wang 2026-09-15 15:41:39 -07:00 committed by GitHub
commit 6ee1d72ae8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 132 additions and 14 deletions

View file

@ -1500,19 +1500,33 @@ def convert_to_gemini_tool_call_result(
return _part
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
"""
Sanitize tool_use_id to match Anthropic's required pattern: ^[a-zA-Z0-9_-]+$
_TOOL_USE_ID_FALLBACK: Final = "tool_use_id"
_ANTHROPIC_TOOL_USE_ID_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]")
_BEDROCK_TOOL_USE_ID_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_.:-]")
_BEDROCK_TOOL_USE_ID_MAX_LEN: Final = 64
_BEDROCK_TOOL_USE_ID_HASH_LEN: Final = 8
Anthropic requires tool_use_id to only contain alphanumeric characters, underscores, and hyphens.
This function replaces any invalid characters with underscores.
def _replace_invalid_tool_use_id_chars(tool_use_id: str, invalid_chars: re.Pattern[str]) -> str:
return invalid_chars.sub("_", tool_use_id) or _TOOL_USE_ID_FALLBACK
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
"""Anthropic requires tool_use_id to match ^[a-zA-Z0-9_-]+$."""
return _replace_invalid_tool_use_id_chars(tool_use_id, _ANTHROPIC_TOOL_USE_ID_INVALID_CHARS)
def _sanitize_bedrock_tool_use_id(tool_use_id: str) -> str:
"""
# Replace any character that's not alphanumeric, underscore, or hyphen with underscore
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_use_id)
# Ensure it's not empty (fallback to a default if needed)
if not sanitized:
sanitized = "tool_use_id"
return sanitized
Bedrock Converse requires toolUseId to match [a-zA-Z0-9_.:-]+ and be at most 64 chars.
Ids that need rewriting get a short hash of the original appended so two ids that only
differ in a replaced char or past the cut still map to distinct values.
"""
sanitized: Final = _replace_invalid_tool_use_id_chars(tool_use_id, _BEDROCK_TOOL_USE_ID_INVALID_CHARS)
if sanitized == tool_use_id and len(sanitized) <= _BEDROCK_TOOL_USE_ID_MAX_LEN:
return sanitized
digest: Final = hashlib.sha256(tool_use_id.encode()).hexdigest()[:_BEDROCK_TOOL_USE_ID_HASH_LEN]
return f"{sanitized[: _BEDROCK_TOOL_USE_ID_MAX_LEN - _BEDROCK_TOOL_USE_ID_HASH_LEN - 1]}_{digest}"
_ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES: Final = {"application/pdf", "text/plain"}
@ -3661,7 +3675,9 @@ def _convert_to_bedrock_tool_call_invoke(
if parsed_objects:
# First object keeps the original tool id.
for obj_idx, obj in enumerate(parsed_objects):
block_id = tool_id if obj_idx == 0 else f"{tool_id}_{obj_idx}"
block_id = _sanitize_bedrock_tool_use_id(
tool_id if obj_idx == 0 else f"{tool_id}_{obj_idx}"
)
bedrock_tool = BedrockToolUseBlock(input=obj, name=name, toolUseId=block_id)
_parts_list.append(BedrockContentBlock(toolUse=bedrock_tool))
# cache_control applies to the whole original
@ -3678,7 +3694,9 @@ def _convert_to_bedrock_tool_call_invoke(
# Fallback: no objects extracted — use empty dict.
arguments_dict = {}
bedrock_tool = BedrockToolUseBlock(input=arguments_dict, name=name, toolUseId=tool_id)
bedrock_tool = BedrockToolUseBlock(
input=arguments_dict, name=name, toolUseId=_sanitize_bedrock_tool_use_id(tool_id)
)
bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool)
_parts_list.append(bedrock_content_block)
@ -3849,7 +3867,7 @@ def _convert_to_bedrock_tool_call_result(
tool_result_content_blocks, used_search_results = _build_bedrock_tool_result_content_blocks(message)
message.get("name", "")
id: Final = str(message.get("tool_call_id", str(uuid.uuid4())))
id: Final = _sanitize_bedrock_tool_use_id(str(message.get("tool_call_id", str(uuid.uuid4()))))
tool_result: Final = BedrockToolResultBlock(content=tool_result_content_blocks, toolUseId=id)
if used_search_results:

View file

@ -2,6 +2,7 @@ import base64
import json
import logging
import os
import re
from typing import Final
from unittest.mock import MagicMock, patch
@ -19,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
_convert_to_bedrock_tool_call_invoke,
_convert_to_bedrock_tool_call_result,
anthropic_messages_pt,
convert_to_anthropic_tool_result,
convert_to_gemini_tool_call_result,
make_valid_bedrock_tool_name,
ollama_pt,
@ -2208,6 +2210,104 @@ def test_bedrock_tool_call_invoke_empty_arguments():
assert result[0]["toolUse"]["input"] == {}
_BEDROCK_TOOL_USE_ID_RE = re.compile(r"^[a-zA-Z0-9_.:-]{1,64}$")
@pytest.mark.parametrize(
"tool_call_id",
[
"call_" + "x" * 100,
"call|with|pipes",
"call_" + "y" * 60 + "|end",
"call:ok.dots-and_under",
"",
],
)
def test_bedrock_tool_use_id_is_sanitized_consistently_for_invoke_and_result(tool_call_id):
"""
Regression test for https://github.com/BerriAI/litellm/issues/34239: client-minted
tool_call ids longer than 64 chars or with chars outside [a-zA-Z0-9_.:-] made Bedrock
return a 400. The invoke and result paths must produce the same valid toolUseId so the
toolUse/toolResult pair still correlates.
"""
invoke = _convert_to_bedrock_tool_call_invoke(
[
{
"id": tool_call_id,
"type": "function",
"function": {"name": "get_weather", "arguments": '{"location": "Boston"}'},
}
]
)
result = _convert_to_bedrock_tool_call_result(
{"tool_call_id": tool_call_id, "role": "tool", "name": "get_weather", "content": "sunny"}
)
tool_use_id = invoke[0]["toolUse"]["toolUseId"]
assert _BEDROCK_TOOL_USE_ID_RE.match(tool_use_id)
assert result["toolResult"]["toolUseId"] == tool_use_id
def test_bedrock_tool_use_id_valid_ids_pass_through_unchanged():
result = _convert_to_bedrock_tool_call_result(
{"tool_call_id": "tooluse_Ab.c:1-2_3", "role": "tool", "name": "f", "content": "ok"}
)
assert result["toolResult"]["toolUseId"] == "tooluse_Ab.c:1-2_3"
def test_bedrock_tool_use_id_truncation_keeps_distinct_ids_distinct():
prefix = "call_" + "z" * 70
ids = {
_convert_to_bedrock_tool_call_result(
{"tool_call_id": f"{prefix}{suffix}", "role": "tool", "name": "f", "content": "ok"}
)["toolResult"]["toolUseId"]
for suffix in ("a", "b")
}
assert len(ids) == 2
assert all(len(i) == 64 for i in ids)
def test_bedrock_tool_use_id_replaced_chars_do_not_collide_with_existing_ids():
ids = {
_convert_to_bedrock_tool_call_result({"tool_call_id": i, "role": "tool", "name": "f", "content": "ok"})[
"toolResult"
]["toolUseId"]
for i in ("call|x", "call_x")
}
assert len(ids) == 2
def test_bedrock_tool_call_invoke_concatenated_json_long_id_stays_within_limit():
long_id = "call_" + "q" * 62
result = _convert_to_bedrock_tool_call_invoke(
[
{
"id": long_id,
"type": "function",
"function": {"name": "run", "arguments": '{"cmd":"a"}{"cmd":"b"}'},
}
]
)
ids = [block["toolUse"]["toolUseId"] for block in result]
assert len(ids) == 2
assert len(set(ids)) == 2
assert all(_BEDROCK_TOOL_USE_ID_RE.match(i) for i in ids)
@pytest.mark.parametrize(
("tool_call_id", "expected"),
[
("call|with|pipes", "call_with_pipes"),
("call:ok.dots", "call_ok_dots"),
("call_" + "x" * 100, "call_" + "x" * 100),
("toolu_01AbC-xyz", "toolu_01AbC-xyz"),
("", "tool_use_id"),
],
)
def test_anthropic_tool_use_id_keeps_pattern_only_rewrite_with_no_cap_or_hash(tool_call_id, expected):
result = convert_to_anthropic_tool_result({"role": "tool", "tool_call_id": tool_call_id, "content": "ok"})
assert result["tool_use_id"] == expected
def test_bedrock_tool_call_invoke_concatenated_json():
"""
Tool call whose arguments contain multiple concatenated JSON objects