diff --git a/litellm/constants.py b/litellm/constants.py index 69ff3cf7a5e..8ca6fd56940 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -529,6 +529,9 @@ NON_LLM_CONNECTION_TIMEOUT: Final = int( MAX_EXCEPTION_MESSAGE_LENGTH: Final = int(os.getenv("MAX_EXCEPTION_MESSAGE_LENGTH", 2000)) MAX_STRING_LENGTH_PROMPT_IN_DB: Final = int(os.getenv("MAX_STRING_LENGTH_PROMPT_IN_DB", 2048)) BEDROCK_MAX_POLICY_SIZE: Final = int(os.getenv("BEDROCK_MAX_POLICY_SIZE", 75)) +# Bedrock Runtime hard-limits tool names to 64 characters; this is a fixed AWS API +# constraint, not a tunable, so it is a plain constant rather than an env var. +BEDROCK_MAX_TOOL_NAME_LENGTH: Final = 64 # One entry per distinct AWS credential-argument set. Per-user cost attribution passes the attributed # identity as aws_session_name, so this bounds how many attributed identities keep a cached STS session. BEDROCK_IAM_CACHE_MAX_ENTRIES: Final = 1000 diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 90a60a9e46c..60fdf1710ff 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -18,7 +18,7 @@ import litellm.types import litellm.types.llms from litellm import verbose_logger from litellm._uuid import uuid -from litellm.constants import REDACTED_BY_LITELLM +from litellm.constants import BEDROCK_MAX_TOOL_NAME_LENGTH, REDACTED_BY_LITELLM from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import anthropic_system_messages from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client @@ -4968,7 +4968,11 @@ def _bedrock_converse_messages_pt( def make_valid_bedrock_tool_name(input_tool_name: str) -> str: - """Normalize tool names to Bedrock pattern [a-zA-Z][a-zA-Z0-9_-]*.""" + """Normalize tool names to Bedrock pattern [a-zA-Z][a-zA-Z0-9_-]* within the 64-char limit. + + Note: scoped to Amazon Bedrock Runtime (the Converse / InvokeModel tool path). + Only Bedrock tool names are normalized here; other providers are unaffected. + """ def replace_invalid(char): if char.isalnum() or char in ("_", "-"): @@ -4986,12 +4990,22 @@ def make_valid_bedrock_tool_name(input_tool_name: str) -> str: # Replace any invalid characters with underscores valid_string: Final = "".join(replace_invalid(char) for char in bedrock_tool_name) - if input_tool_name != valid_string: + # Enforce Bedrock's max tool-name length. Truncate and append a short + # deterministic hash so distinct long names don't collide and the same + # input always maps to the same output (so toolSpec and toolUse match). + if len(valid_string) > BEDROCK_MAX_TOOL_NAME_LENGTH: + name_hash = hashlib.sha256(valid_string.encode()).hexdigest()[:8] + prefix_length = BEDROCK_MAX_TOOL_NAME_LENGTH - len(name_hash) - 1 + final_name = f"{valid_string[:prefix_length]}_{name_hash}" + else: + final_name = valid_string + + if input_tool_name != final_name: # passed tool name was formatted to become valid # store it internally so we can use for the response - litellm.bedrock_tool_name_mappings.set_cache(key=valid_string, value=input_tool_name) + litellm.bedrock_tool_name_mappings.set_cache(key=final_name, value=input_tool_name) - return valid_string + return final_name def add_cache_point_tool_block(tool: dict, model: str | None = None) -> BedrockToolBlock | None: diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 8d2e6b9fd0c..4f1fadbed38 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -2462,6 +2462,43 @@ def test_make_valid_bedrock_tool_name_preserves_hyphens(): ) +def test_make_valid_bedrock_tool_name_truncates_and_restores_long_name(): + """Names over Bedrock's 64-char limit are truncated and mapped back on response.""" + import litellm + + raw_name = "mcp__codex_apps__atlassian_rovo___getAccessibleAtlassianResources" + assert len(raw_name) > 64 + + valid = make_valid_bedrock_tool_name(raw_name) + assert len(valid) == 64 + assert re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", valid) + # the original name is recoverable on the response path + assert litellm.bedrock_tool_name_mappings.get_cache(key=valid) == raw_name + + +def test_make_valid_bedrock_tool_name_is_deterministic(): + """Same input always yields the same sanitized name (toolSpec == toolUse).""" + raw_name = "a" * 80 + assert make_valid_bedrock_tool_name(raw_name) == make_valid_bedrock_tool_name( + raw_name + ) + + +def test_make_valid_bedrock_tool_name_avoids_collision_on_shared_prefix(): + """Two long names sharing a >64-char prefix must not collapse to the same value.""" + name_a = "shared_prefix_" + "a" * 60 + "_alpha" + name_b = "shared_prefix_" + "a" * 60 + "_beta" + assert len(name_a) > 64 and len(name_b) > 64 + assert make_valid_bedrock_tool_name(name_a) != make_valid_bedrock_tool_name(name_b) + + +def test_make_valid_bedrock_tool_name_boundary_64_unchanged(): + """A valid name of exactly 64 chars is passed through untouched.""" + name = "a" + "b" * 63 + assert len(name) == 64 + assert make_valid_bedrock_tool_name(name) == name + + def test_bedrock_tool_name_sanitized_consistently_in_tools_and_tool_use(): """toolSpec and toolUse names must match after sanitization (issue #5007).""" raw_name = "foo@bar"