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
This commit is contained in:
Sameer Kankute 2026-04-09 09:53:59 +05:30 committed by Yuneng Jiang
parent e3d7412e1f
commit bc30625a08
No known key found for this signature in database
6 changed files with 243 additions and 10 deletions

View file

@ -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", []),
)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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)."""