mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-13 07:06:08 +00:00
Merge pull request #25396 from BerriAI/litellm_bedrock-normalize-custom-tool-schema
feat(bedrock): normalize custom tool JSON schema for Invoke and Converse
This commit is contained in:
@@ -5144,26 +5144,44 @@ 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:
|
||||
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
|
||||
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)
|
||||
|
||||
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=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:
|
||||
@@ -5176,9 +5194,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", []),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,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
|
||||
@@ -174,6 +175,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
|
||||
|
||||
# Remove `custom` field from tools (Bedrock doesn't support it)
|
||||
remove_custom_field_from_tools(anthropic_request)
|
||||
normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_request)
|
||||
return anthropic_request
|
||||
|
||||
def _compute_bedrock_invoke_beta_headers(
|
||||
|
||||
@@ -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,88 @@ 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\"`` (iterative walk).
|
||||
|
||||
Anthropic / Claude Code use ``custom`` for tool schemas; Bedrock Invoke and
|
||||
Bedrock Converse only accept standard JSON Schema type strings.
|
||||
|
||||
Uses an explicit stack (not recursion) to satisfy recursive-function guards in CI.
|
||||
"""
|
||||
stack: List[Any] = [schema]
|
||||
seen: set[int] = set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_id = id(node)
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
if node.get("type") == "custom":
|
||||
node["type"] = "object"
|
||||
items = node.get("items")
|
||||
if isinstance(items, dict):
|
||||
stack.append(items)
|
||||
addl = node.get("additionalProperties")
|
||||
if isinstance(addl, dict):
|
||||
stack.append(addl)
|
||||
props = node.get("properties")
|
||||
if isinstance(props, dict):
|
||||
for sub in props.values():
|
||||
if isinstance(sub, dict):
|
||||
stack.append(sub)
|
||||
for combiner in ("allOf", "anyOf", "oneOf"):
|
||||
arr = node.get(combiner)
|
||||
if isinstance(arr, list):
|
||||
for sub in arr:
|
||||
if isinstance(sub, dict):
|
||||
stack.append(sub)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
+4
@@ -25,8 +25,10 @@ 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,
|
||||
remove_custom_field_from_tools,
|
||||
)
|
||||
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
|
||||
@@ -426,6 +428,8 @@ 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)
|
||||
ensure_bedrock_anthropic_messages_tool_names(anthropic_messages_request)
|
||||
|
||||
# 6. AUTO-INJECT beta headers based on features used
|
||||
anthropic_model_info = AnthropicModelInfo()
|
||||
|
||||
@@ -1066,6 +1066,72 @@ 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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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"
|
||||
|
||||
assert result[2]["toolSpec"]["name"] == "litellm_unnamed_tool_2"
|
||||
|
||||
|
||||
def test_bedrock_tools_transformation_valid_params():
|
||||
from litellm.types.llms.bedrock import ToolJsonSchemaBlock
|
||||
|
||||
|
||||
+18
@@ -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.
|
||||
|
||||
+143
-1
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -12,7 +13,11 @@ 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 (
|
||||
ensure_bedrock_anthropic_messages_tool_names,
|
||||
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,
|
||||
@@ -294,6 +299,143 @@ def test_remove_custom_field_from_tools():
|
||||
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)."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user