From bc30625a08287f2eecc142a65d00e40d17d2cffa Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 09:53:59 +0530 Subject: [PATCH] feat(bedrock): normalize custom tool JSON schema for Invoke and Converse Anthropic/Claude Code use input_schema.type "custom"; Bedrock rejects it. - Add normalize_json_schema_custom_types_to_object and use it for Invoke, chat invoke, and _bedrock_tools_pt (Anthropic input_schema + OpenAI params). - Coerce invalid root types to object for Converse toolSpec. - Tests for invoke transform, converse _bedrock_tools_pt, and unit helper. Made-with: Cursor --- .../prompt_templates/factory.py | 34 ++++-- .../anthropic_claude3_transformation.py | 2 + litellm/llms/bedrock/common_utils.py | 57 +++++++++- .../anthropic_claude3_transformation.py | 2 + .../test_bedrock_completion.py | 58 ++++++++++ .../test_anthropic_claude3_transformation.py | 100 +++++++++++++++++- 6 files changed, 243 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d29ca1649ff..397eaaad0d4 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -5142,8 +5142,14 @@ def _bedrock_tools_pt(tools: List) -> List[BedrockToolBlock]: } ] """ + from litellm.llms.bedrock.common_utils import ( + normalize_json_schema_custom_types_to_object, + ) from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs + _valid_json_schema_root_types = frozenset( + ("array", "boolean", "integer", "null", "number", "object", "string") + ) tool_block_list: List[BedrockToolBlock] = [] for tool in tools: # Check if tool is already a BedrockToolBlock (e.g., systemTool for Nova grounding) @@ -5152,16 +5158,25 @@ def _bedrock_tools_pt(tools: List) -> List[BedrockToolBlock]: tool_block_list.append(tool) # type: ignore continue - # Handle regular OpenAI-style function tools - parameters = tool.get("function", {}).get( - "parameters", {"type": "object", "properties": {}} - ) - name = tool.get("function", {}).get("name", "") + # OpenAI function tools, or Anthropic Messages / Claude Code ({name, input_schema, type, ...}) + if isinstance(tool, dict) and "input_schema" in tool and "function" not in tool: + parameters = copy.deepcopy( + tool.get("input_schema") or {"type": "object", "properties": {}} + ) + raw_name = tool.get("name", "") or "" + _tool_description = tool.get("description", None) + else: + parameters = copy.deepcopy( + tool.get("function", {}).get( + "parameters", {"type": "object", "properties": {}} + ) + ) + raw_name = tool.get("function", {}).get("name", "") or "" + _tool_description = tool.get("function", {}).get("description", None) # 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=name) - _tool_description = tool.get("function", {}).get("description", None) + name = make_valid_bedrock_tool_name(input_tool_name=raw_name) if _tool_description: # bedrock doesn't accept empty "" or None descriptions description = _tool_description else: @@ -5174,9 +5189,12 @@ def _bedrock_tools_pt(tools: List) -> List[BedrockToolBlock]: # with circular references (see issue #19098). unpack_defs handles nested # refs recursively and correctly detects/skips circular references. unpack_defs(parameters, defs_copy) + normalize_json_schema_custom_types_to_object(parameters) + if parameters.get("type") not in _valid_json_schema_root_types: + parameters["type"] = "object" tool_input_schema = BedrockToolInputSchemaBlock( json=BedrockToolJsonSchemaBlock( - type=parameters.get("type", ""), + type=parameters["type"], properties=parameters.get("properties", {}), required=parameters.get("required", []), ) diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index 7936b6ea644..973752103ee 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -8,6 +8,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation ) from litellm.llms.bedrock.common_utils import ( get_anthropic_beta_from_headers, + normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER @@ -118,6 +119,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): # which causes Bedrock to reject the request with "Extra inputs are not permitted" # Ref: https://github.com/BerriAI/litellm/issues/22847 remove_custom_field_from_tools(_anthropic_request) + normalize_tool_input_schema_types_for_bedrock_invoke(_anthropic_request) tools = optional_params.get("tools") tool_search_used = self.is_tool_search_used(tools) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 9666aa68c99..f99f0102d83 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -6,7 +6,7 @@ Common utilities used across bedrock chat/embedding/image generation import json import os -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union if TYPE_CHECKING: from litellm.types.llms.bedrock import BedrockCreateBatchRequest @@ -70,6 +70,61 @@ def remove_custom_field_from_tools(request_body: dict) -> None: tool.pop("custom", None) +def normalize_json_schema_custom_types_to_object(schema: dict) -> None: + """ + In-place: replace JSON Schema ``type: \"custom\"`` with ``\"object\"`` recursively. + + Anthropic / Claude Code use ``custom`` for tool schemas; Bedrock Invoke and + Bedrock Converse only accept standard JSON Schema type strings. + """ + + def _fix_schema(node: Any) -> None: + if not isinstance(node, dict): + return + if node.get("type") == "custom": + node["type"] = "object" + items = node.get("items") + if isinstance(items, dict): + _fix_schema(items) + addl = node.get("additionalProperties") + if isinstance(addl, dict): + _fix_schema(addl) + props = node.get("properties") + if isinstance(props, dict): + for sub in props.values(): + _fix_schema(sub) + for combiner in ("allOf", "anyOf", "oneOf"): + arr = node.get(combiner) + if isinstance(arr, list): + for sub in arr: + _fix_schema(sub) + + _fix_schema(schema) + + +def normalize_tool_input_schema_types_for_bedrock_invoke(request_body: dict) -> None: + """ + Bedrock Invoke (Anthropic Messages) validates ``input_schema`` as JSON Schema. + Anthropic's API allows ``type: \"custom\"`` for Claude Code custom tools; Bedrock + rejects it with: ``tools.0.custom.input_schema.type: Input should be 'object'``. + + Normalizes ``type: \"custom\"`` to ``\"object\"`` throughout each tool's + ``input_schema`` (recursive for nested properties, items, combinators). + + Args: + request_body: Request dictionary to modify in-place. + """ + tools = request_body.get("tools") + if not tools or not isinstance(tools, list): + return + for tool in tools: + if not isinstance(tool, dict): + continue + input_schema = tool.get("input_schema") + if isinstance(input_schema, dict): + normalize_json_schema_custom_types_to_object(input_schema) + + 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 e31820d7631..9826f7cb348 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -26,6 +26,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation from litellm.llms.bedrock.common_utils import ( get_anthropic_beta_from_headers, is_claude_4_5_on_bedrock, + normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER @@ -428,6 +429,7 @@ class AmazonAnthropicClaudeMessagesConfig( # which causes Bedrock to reject the request with "Extra inputs are not permitted" # 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) # 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 b71e4e51877..13b12c28b8c 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -1120,6 +1120,64 @@ def test_bedrock_tools_pt_invalid_names(): assert result[1]["toolSpec"]["name"] == "another_invalid_name" +def test_bedrock_converse_tools_pt_converts_custom_schema_type_to_object(): + """ + Bedrock Converse ``toolSpec.inputSchema.json`` must use standard JSON Schema + types. Anthropic / Claude Code use ``type: \"custom\"`` in ``input_schema`` (or + OpenAI ``parameters``); ``_bedrock_tools_pt`` must convert ``custom`` → ``object`` + at the root and inside nested ``properties``. + """ + tools = [ + { + "name": "Agent", + "description": "Subagent tool", + "type": "custom", + "input_schema": { + "type": "custom", + "additionalProperties": False, + "properties": { + "prompt": {"type": "string"}, + "nested": { + "type": "custom", + "properties": {"x": {"type": "string"}}, + "required": ["x"], + }, + }, + "required": ["prompt"], + }, + }, + { + "type": "function", + "function": { + "name": "other", + "description": "x", + "parameters": { + "type": "custom", + "properties": { + "a": {"type": "integer"}, + "nested_obj": { + "type": "custom", + "properties": {"b": {"type": "string"}}, + }, + }, + "required": ["a"], + }, + }, + }, + ] + + result = _bedrock_tools_pt(tools) + + assert result[0]["toolSpec"]["name"] == "Agent" + j0 = result[0]["toolSpec"]["inputSchema"]["json"] + assert j0["type"] == "object" + assert j0["properties"]["nested"]["type"] == "object" + + j1 = result[1]["toolSpec"]["inputSchema"]["json"] + assert j1["type"] == "object" + assert j1["properties"]["nested_obj"]["type"] == "object" + + def test_bedrock_tools_transformation_valid_params(): from litellm.types.llms.bedrock import ToolJsonSchemaBlock 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 f69f478278f..6b604b6a6f1 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 @@ -1,4 +1,5 @@ import asyncio +import copy import json import os import sys @@ -11,7 +12,10 @@ import pytest 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 remove_custom_field_from_tools +from litellm.llms.bedrock.common_utils import ( + normalize_tool_input_schema_types_for_bedrock_invoke, + remove_custom_field_from_tools, +) from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, AmazonAnthropicClaudeMessagesStreamDecoder, @@ -232,6 +236,100 @@ 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_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)."""