diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index b37c17fafe..a037360c87 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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", []), ) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index ed49943b7f..924205b159 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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 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 2b28473ad3..cff415d49e 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -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( diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 9666aa68c9..6f4f3c3f18 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,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 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 3ac579dae1..d00a18fe7e 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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() diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py index 76ec2bdd1d..67e0535db5 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -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 diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ae970e1ff0..197aa9ab90 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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. 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 570e11e1bb..f0186f7891 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 @@ -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)."""