From 929331bf839e3b10db47f66c87dac2f2121d2515 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 10:39:00 +0530 Subject: [PATCH] fix(bedrock): custom tool schemas and missing tool names for Converse Normalize JSON Schema type custom to object for Bedrock invoke and _bedrock_tools_pt, ensure stable names for tools without name, and avoid KeyError in the Anthropic messages adapter when translating tools to OpenAI format for bedrock/converse. Made-with: Cursor --- .../prompt_templates/factory.py | 5 +- .../adapters/transformation.py | 10 +- litellm/llms/bedrock/common_utils.py | 21 ++- .../anthropic_claude3_transformation.py | 2 + .../test_bedrock_completion.py | 8 + ...al_pass_through_adapters_transformation.py | 18 +++ .../test_anthropic_claude3_transformation.py | 139 ++++++++++++++++++ 7 files changed, 199 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 397eaaad0d4..75cea8c1ccd 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -5151,7 +5151,7 @@ def _bedrock_tools_pt(tools: List) -> List[BedrockToolBlock]: ("array", "boolean", "integer", "null", "number", "object", "string") ) tool_block_list: List[BedrockToolBlock] = [] - for tool in tools: + for tool_idx, tool in enumerate(tools): # Check if tool is already a BedrockToolBlock (e.g., systemTool for Nova grounding) if _is_bedrock_tool_block(tool): # Already a BedrockToolBlock, pass it through @@ -5174,6 +5174,9 @@ def _bedrock_tools_pt(tools: List) -> List[BedrockToolBlock]: raw_name = tool.get("function", {}).get("name", "") or "" _tool_description = tool.get("function", {}).get("description", None) + if not (raw_name and str(raw_name).strip()): + raw_name = f"litellm_unnamed_tool_{tool_idx}" + # related issue: https://github.com/BerriAI/litellm/issues/5007 # Bedrock tool names must satisfy regular expression pattern: [a-zA-Z][a-zA-Z0-9_]* ensure this is true name = make_valid_bedrock_tool_name(input_tool_name=raw_name) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index ed49943b7fe..924205b1593 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -796,7 +796,7 @@ class LiteLLMAnthropicMessagesAdapter: tool_name_mapping: Dict[str, str] = {} mapped_tool_params = ["name", "input_schema", "description", "cache_control"] - for tool in tools: + for idx, tool in enumerate(tools): # Check if this is an Anthropic-native tool that should be kept as-is tool_type = tool.get("type", "") if any(tool_type.startswith(t.value) for t in ANTHROPIC_HOSTED_TOOLS): @@ -804,7 +804,13 @@ class LiteLLMAnthropicMessagesAdapter: new_tools.append(tool) # type: ignore[arg-type] continue - original_name = tool["name"] + raw_name = tool.get("name") + if raw_name is None or ( + isinstance(raw_name, str) and not str(raw_name).strip() + ): + original_name = f"litellm_unnamed_tool_{idx}" + else: + original_name = str(raw_name) truncated_name = truncate_tool_name(original_name) # Store mapping if name was truncated diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index a8d2fb9ad72..6f4f3c3f18a 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -72,7 +72,7 @@ def remove_custom_field_from_tools(request_body: dict) -> None: def normalize_json_schema_custom_types_to_object(schema: dict) -> None: """ - In-place: replace JSON Schema ``type: \"custom\"`` with ``\"object`` (iterative walk). + In-place: replace JSON Schema ``type: \"custom\"`` with ``\"object\"`` (iterative walk). Anthropic / Claude Code use ``custom`` for tool schemas; Bedrock Invoke and Bedrock Converse only accept standard JSON Schema type strings. @@ -133,6 +133,25 @@ def normalize_tool_input_schema_types_for_bedrock_invoke(request_body: dict) -> normalize_json_schema_custom_types_to_object(input_schema) +def ensure_bedrock_anthropic_messages_tool_names(request_body: dict) -> None: + """ + Bedrock Invoke (Anthropic Messages) requires each tool to include ``name``. + Some clients send only ``input_schema``; Bedrock then errors with + ``tools.0.custom.name: Field required``. + + In-place: set ``name`` to ``litellm_unnamed_tool_{index}`` when missing or blank. + """ + tools = request_body.get("tools") + if not tools or not isinstance(tools, list): + return + for i, tool in enumerate(tools): + if not isinstance(tool, dict): + continue + name = tool.get("name") + if name is None or (isinstance(name, str) and not name.strip()): + tool["name"] = f"litellm_unnamed_tool_{i}" + + class AmazonBedrockGlobalConfig: def __init__(self): pass 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 9826f7cb348..17431273d7b 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -24,6 +24,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation AmazonInvokeConfig, ) from litellm.llms.bedrock.common_utils import ( + ensure_bedrock_anthropic_messages_tool_names, get_anthropic_beta_from_headers, is_claude_4_5_on_bedrock, normalize_tool_input_schema_types_for_bedrock_invoke, @@ -430,6 +431,7 @@ class AmazonAnthropicClaudeMessagesConfig( # Ref: https://github.com/BerriAI/litellm/issues/22847 remove_custom_field_from_tools(anthropic_messages_request) normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_messages_request) + ensure_bedrock_anthropic_messages_tool_names(anthropic_messages_request) # 6. AUTO-INJECT beta headers based on features used anthropic_model_info = AnthropicModelInfo() diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py index 13b12c28b8c..68df09bb07a 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -1164,6 +1164,12 @@ def test_bedrock_converse_tools_pt_converts_custom_schema_type_to_object(): }, }, }, + { + "input_schema": { + "type": "object", + "properties": {"q": {"type": "string"}}, + }, + }, ] result = _bedrock_tools_pt(tools) @@ -1177,6 +1183,8 @@ def test_bedrock_converse_tools_pt_converts_custom_schema_type_to_object(): assert j1["type"] == "object" assert j1["properties"]["nested_obj"]["type"] == "object" + assert result[2]["toolSpec"]["name"] == "litellm_unnamed_tool_2" + def test_bedrock_tools_transformation_valid_params(): from litellm.types.llms.bedrock import ToolJsonSchemaBlock diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ae970e1ff06..197aa9ab905 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1420,6 +1420,24 @@ def test_cache_control_not_preserved_in_tools_for_non_claude(): assert "cache_control" not in result[0] +def test_translate_anthropic_tools_to_openai_fills_missing_tool_name(): + """Schema-only tools (no ``name``) must not crash the Converse adapter path.""" + tools = [ + { + "input_schema": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + {"name": "", "input_schema": {"type": "object", "properties": {}}}, + ] + adapter = LiteLLMAnthropicMessagesAdapter() + result, _ = adapter.translate_anthropic_tools_to_openai(tools=tools, model=None) + assert result[0]["function"]["name"] == "litellm_unnamed_tool_0" + assert result[1]["function"]["name"] == "litellm_unnamed_tool_1" + + def test_translate_openai_content_to_anthropic_reasoning_content_without_thinking_blocks(): """ Test that reasoning_content is converted to thinking block when thinking_blocks is not present. 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 56dc9b42cb4..51a0d31cd15 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 @@ -13,6 +13,7 @@ sys.path.insert(0, os.path.abspath("../../../../../..")) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.bedrock.common_utils import ( + ensure_bedrock_anthropic_messages_tool_names, normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) @@ -236,6 +237,144 @@ def test_remove_custom_field_from_tools(): remove_custom_field_from_tools(request4) assert request4["tools"] is None + +def test_normalize_tool_input_schema_types_for_bedrock_invoke(): + """ + Claude Code sends ``input_schema.type: \"custom\"`` for custom tools. + Bedrock Invoke rejects this; it requires JSON Schema ``type: \"object\"``. + """ + + request = { + "tools": [ + { + "name": "Agent", + "type": "custom", + "description": "subagent", + "input_schema": { + "type": "custom", + "additionalProperties": False, + "properties": { + "nested": {"type": "custom", "properties": {"x": {"type": "string"}}} + }, + "required": ["nested"], + }, + }, + { + "name": "Read", + "input_schema": {"type": "object", "properties": {}}, + }, + ] + } + + normalize_tool_input_schema_types_for_bedrock_invoke(request) + + agent_tool = request["tools"][0] + assert agent_tool["type"] == "custom" + assert agent_tool["input_schema"]["type"] == "object" + assert agent_tool["input_schema"]["properties"]["nested"]["type"] == "object" + assert request["tools"][1]["input_schema"]["type"] == "object" + + request2 = {"messages": []} + normalize_tool_input_schema_types_for_bedrock_invoke(request2) + assert request2 == {"messages": []} + + +def test_ensure_bedrock_anthropic_messages_tool_names(): + request = { + "tools": [ + {"input_schema": {"type": "object", "properties": {}}}, + {"name": "", "input_schema": {"type": "object", "properties": {}}}, + {"name": " ", "input_schema": {"type": "object", "properties": {}}}, + {"name": "KeepMe", "input_schema": {"type": "object", "properties": {}}}, + ] + } + ensure_bedrock_anthropic_messages_tool_names(request) + assert request["tools"][0]["name"] == "litellm_unnamed_tool_0" + assert request["tools"][1]["name"] == "litellm_unnamed_tool_1" + assert request["tools"][2]["name"] == "litellm_unnamed_tool_2" + assert request["tools"][3]["name"] == "KeepMe" + + +def test_bedrock_invoke_messages_transform_adds_name_when_tool_missing_name(): + """Bedrock requires tools.0.custom.name when the payload is schema-only.""" + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 128, + "tools": [ + { + "input_schema": { + "type": "object", + "properties": {"questions": {"type": "array"}}, + "required": ["questions"], + }, + } + ], + "stream": False, + } + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert result["tools"][0]["name"] == "litellm_unnamed_tool_0" + + +def test_bedrock_invoke_messages_transform_converts_custom_tool_schema_type_to_object(): + """ + End-to-end: AmazonAnthropicClaudeMessagesConfig must emit Bedrock Invoke bodies + where every ``input_schema`` uses JSON Schema types (``object``), not Anthropic + ``type: \"custom\"`` (root and nested). + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + tools = [ + { + "name": "Agent", + "type": "custom", + "description": "Subagent", + "input_schema": { + "type": "custom", + "additionalProperties": False, + "properties": { + "prompt": {"type": "string"}, + "nested": { + "type": "custom", + "properties": {"x": {"type": "string"}}, + "required": ["x"], + }, + }, + "required": ["prompt"], + }, + } + ] + optional_params = { + "max_tokens": 256, + "tools": copy.deepcopy(tools), + "stream": False, + } + messages = [{"role": "user", "content": "hi"}] + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "tools" in result + schema = result["tools"][0]["input_schema"] + assert schema["type"] == "object" + assert schema["properties"]["nested"]["type"] == "object" + # Tool discriminator stays Anthropic-side; only input_schema is normalized + assert result["tools"][0]["type"] == "custom" + + def test_remove_scope_from_cache_control(): """Ensure scope field is removed from cache_control for Bedrock (not supported)."""