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
This commit is contained in:
Sameer Kankute 2026-04-09 10:39:00 +05:30
parent 64164af7aa
commit 929331bf83
No known key found for this signature in database
7 changed files with 199 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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