Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions litellm/litellm_core_utils/prompt_templates/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5142,26 +5142,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")
)
Comment thread
Sameerlite marked this conversation as resolved.
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:
Expand All @@ -5174,9 +5192,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", []),
)
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this change do? @Sameerlite

Original file line number Diff line number Diff line change
Expand Up @@ -796,15 +796,21 @@ def translate_anthropic_tools_to_openai(
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):
# Keep Anthropic-native tools in their original format
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}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krrish-berri-2 The code in adapters/transformation.py is used for /v1/messages → litellm.completion adapter flows. For Bedrock, that mainly means bedrock/converse/...

else:
original_name = str(raw_name)
truncated_name = truncate_tool_name(original_name)

# Store mapping if name was truncated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
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
Expand Down Expand Up @@ -174,6 +175,7 @@ def _build_bedrock_anthropic_request_base(

# 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(
Expand Down
84 changes: 83 additions & 1 deletion litellm/llms/bedrock/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
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
Expand Down Expand Up @@ -426,6 +428,8 @@ def transform_anthropic_messages_request(
# 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()
Expand Down
66 changes: 66 additions & 0 deletions tests/llm_translation/test_bedrock_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading