From 5ad9dc96e288a046d4aebf3407a1de3b5c166836 Mon Sep 17 00:00:00 2001 From: Leonard Hu Date: Thu, 24 Sep 2026 10:17:19 -0400 Subject: [PATCH 1/3] fix(bedrock): enforce 64-char limit on Bedrock Runtime tool names Bedrock Runtime (Converse / InvokeModel) requires toolSpec.name to match [a-zA-Z][a-zA-Z0-9_-]* and be <= 64 characters. make_valid_bedrock_tool_name sanitized invalid characters but never enforced the length limit, so a valid but long name (e.g. a 65-char MCP tool name) reached Bedrock unchanged and triggered a ValidationException. Truncate names over the limit to the first 55 characters plus an 8-char SHA-256 suffix (55 + "_" + 8 = 64). This is deterministic, so the name in toolSpec matches the name in toolUse, and collision-resistant for long names that share a prefix. The sanitized -> original mapping is cached as before, so get_bedrock_tool_name restores the original name on the response path. Scoped to Bedrock; other providers are unaffected. Adds the BEDROCK_MAX_TOOL_NAME_LENGTH constant (env-overridable) and tests for truncation/restore, determinism, collision avoidance, and the 64-char boundary. Signed-off-by: Leonard Hu --- litellm/constants.py | 1 + .../prompt_templates/factory.py | 24 +++++++++--- ...llm_core_utils_prompt_templates_factory.py | 37 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index e5b662bd515..bcce59e2ec8 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -508,6 +508,7 @@ 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_MAX_TOOL_NAME_LENGTH: Final = int(os.getenv("BEDROCK_MAX_TOOL_NAME_LENGTH", 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 8424187dcbc..a11e27d8ed8 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -16,7 +16,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.url_utils import async_safe_get, safe_get from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client from litellm.types.files import get_file_extension_from_mime_type @@ -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: Final = 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/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 4322662cfcb..dd0db489947 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -2542,6 +2542,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" From dc37d1ec67a1cbcac0200050c2dbb2e02a0cee31 Mon Sep 17 00:00:00 2001 From: Leonard Hu Date: Thu, 24 Sep 2026 10:53:41 -0400 Subject: [PATCH 2/3] fix(bedrock): make tool-name limit a fixed constant, not an env var Bedrock Runtime's 64-char tool-name limit is a fixed AWS API constraint, not a per-deployment tunable. Reading it via os.getenv introduced an undocumented env var, which failed the documentation_test_env_keys code quality check. Define it as a plain constant instead. Signed-off-by: Leonard Hu --- litellm/constants.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/constants.py b/litellm/constants.py index bcce59e2ec8..02bc4be859f 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -508,7 +508,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_MAX_TOOL_NAME_LENGTH: Final = int(os.getenv("BEDROCK_MAX_TOOL_NAME_LENGTH", 64)) +# 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 From 2d50e3755366d16212651f92034b2813b44a3964 Mon Sep 17 00:00:00 2001 From: Leonard Hu Date: Thu, 24 Sep 2026 11:17:42 -0400 Subject: [PATCH 3/3] fix(bedrock): drop Final on reassigned local to satisfy type gate final_name is assigned in both branches of the length check, so annotating it Final tripped basedpyright's reportGeneralTypeIssues (cannot reassign a Final) and failed the type-check budget gate. Make it a plain local. Signed-off-by: Leonard Hu --- litellm/litellm_core_utils/prompt_templates/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index a11e27d8ed8..03cd377acd7 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -4996,7 +4996,7 @@ def make_valid_bedrock_tool_name(input_tool_name: str) -> str: 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: Final = f"{valid_string[:prefix_length]}_{name_hash}" + final_name = f"{valid_string[:prefix_length]}_{name_hash}" else: final_name = valid_string