From ea7671e96f327252c7de0718f5547160a5548970 Mon Sep 17 00:00:00 2001 From: Sovit Date: Sat, 27 Jun 2026 14:26:10 -0700 Subject: [PATCH] fix(anthropic): coerce tool input_schema.type to "object" on /v1/messages pass-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic's /v1/messages requires every tool input_schema to have type == "object" and rejects the request otherwise with: tools.N.custom.input_schema.type: Field required Tools emitted by Claude Code / MCP clients sometimes omit `type`, declaring a JSON-schema "$schema" draft reference instead. The beta /v1/messages pass-through forwarded them verbatim, so these requests 400. ($schema itself is tolerated by Anthropic when type is present — the missing type is the cause.) The /v1/chat/completions path already normalizes this in AnthropicConfig._map_tool_helper; the pass-through path had no equivalent. Add _normalize_tool_input_schemas() and call it in transform_anthropic_messages_request: coerce type -> "object" (ensuring properties), and drop the "$schema"/"$id" reference fields via the shared _remove_json_schema_refs helper (as the Mistral chat transformation does). Inheriting Azure/Vertex/DeepSeek/Bedrock configs benefit via super(). Verified end-to-end against the live Anthropic API: a tool whose input_schema omits `type` returns HTTP 400 before this change and HTTP 200 after. Fixes #24121 --- .../messages/transformation.py | 37 ++++++ .../messages/test_tool_schema_sanitization.py | 113 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_tool_schema_sanitization.py diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index e78802a1587..9cc4088fb64 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -253,6 +253,38 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): existing_output_config.setdefault("effort", effort) optional_params["output_config"] = existing_output_config + @staticmethod + def _normalize_tool_input_schemas(tools: Optional[list[dict]]) -> None: + """ + Normalize each tool's input_schema for Anthropic /v1/messages. + + Anthropic requires ``input_schema.type == "object"`` and rejects the + request otherwise with: + tools.N.custom.input_schema.type: Field required + + Tools emitted by Claude Code / MCP-backed clients sometimes omit ``type`` + (declaring a JSON-schema ``$schema`` draft reference instead). The + /v1/chat/completions path already handles this in + ``AnthropicConfig._map_tool_helper``; the pass-through path had no + equivalent. Mirror that behavior: coerce ``type`` to ``"object"`` (and + ensure ``properties`` exists), and drop the ``$schema``/``$id`` reference + fields via the shared ``_remove_json_schema_refs`` helper (the same helper + the Mistral chat transformation uses). Mutates input_schema in place. + """ + if not tools: + return + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH + from litellm.utils import _remove_json_schema_refs + + for tool in tools: + input_schema = tool.get("input_schema") + if not isinstance(input_schema, dict): + continue + _remove_json_schema_refs(input_schema, max_depth=DEFAULT_MAX_RECURSE_DEPTH) + if input_schema.get("type") != "object": + input_schema["type"] = "object" + input_schema.setdefault("properties", {}) + def transform_anthropic_messages_request( self, model: str, @@ -309,6 +341,11 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): # request body on the hot path). Defer it to when DEBUG is enabled. verbose_logger.debug("TRANSFORMATION DEBUG - Messages: %s", messages) + # Normalize tool input_schema (coerce type->"object", drop $schema/$id). + # Anthropic rejects a missing/non-object type; the chat path already does + # this in _map_tool_helper, but the pass-through path did not. + self._normalize_tool_input_schemas(anthropic_messages_optional_request_params.get("tools")) + # Auto-strip advisor blocks from history if advisor tool is absent. # Prevents Anthropic 400: advisor_tool_result in history requires advisor tool. _tools = anthropic_messages_optional_request_params.get("tools") or [] diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_tool_schema_sanitization.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_tool_schema_sanitization.py new file mode 100644 index 00000000000..9e5f16be299 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_tool_schema_sanitization.py @@ -0,0 +1,113 @@ +"""Tests for tool ``input_schema`` normalization on the Anthropic /v1/messages route. + +Regression coverage for #24121: tools whose ``input_schema`` omits ``type`` (as Claude +Code / MCP clients emit, declaring a JSON-schema ``$schema`` draft reference instead) are +forwarded to Anthropic unchanged on the beta ``/v1/messages`` pass-through and rejected +with:: + + tools.0.custom.input_schema.type: Input should be 'object' + +The root cause is the missing ``type``, not the ``$schema`` field (Anthropic tolerates +``$schema`` when ``type: object`` is present). The ``/v1/chat/completions`` path already +coerces ``type`` -> ``"object"`` in ``_map_tool_helper``; these tests assert the +pass-through path now does the same (and also drops ``$schema``/``$id`` for parity). +""" + +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) + + +def _transform(tools): + return AnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-sonnet-4-5", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params={"max_tokens": 1024, "tools": tools}, + litellm_params={}, + headers={}, + ) + + +def test_missing_type_is_coerced_to_object(): + """THE fix: input_schema without ``type`` must get ``type: "object"`` injected. + + This is exactly the shape that Anthropic rejects with HTTP 400 before the fix. + """ + tool = { + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + input_schema = _transform([tool])["tools"][0]["input_schema"] + assert input_schema["type"] == "object" + # $schema/$id are also stripped for parity with the chat path. + assert "$schema" not in input_schema + # Legitimate fields preserved. + assert input_schema["properties"]["location"]["type"] == "string" + assert input_schema["required"] == ["location"] + + +def test_non_object_type_is_coerced_and_properties_ensured(): + """A root-level non-object ``type`` is coerced, and ``properties`` is injected.""" + tool = { + "name": "echo", + "description": "echo", + "input_schema": {"type": "string"}, + } + input_schema = _transform([tool])["tools"][0]["input_schema"] + assert input_schema["type"] == "object" + assert input_schema["properties"] == {} + + +def test_valid_object_schema_is_preserved(): + """A well-formed object schema is left intact (aside from $schema/$id removal).""" + tool = { + "name": "ok", + "description": "ok", + "input_schema": { + "type": "object", + "properties": {"x": {"type": "string"}}, + "required": ["x"], + }, + } + input_schema = _transform([tool])["tools"][0]["input_schema"] + assert input_schema["type"] == "object" + assert input_schema["properties"] == {"x": {"type": "string"}} + assert input_schema["required"] == ["x"] + + +def test_schema_refs_stripped_recursively(): + """Nested ``$schema``/``$id`` inside properties are also removed.""" + tool = { + "name": "nested", + "description": "nested", + "input_schema": { + "type": "object", + "properties": { + "inner": { + "$id": "https://example.com/x.json", + "type": "object", + "properties": {"x": {"type": "string"}}, + } + }, + }, + } + inner = _transform([tool])["tools"][0]["input_schema"]["properties"]["inner"] + assert "$id" not in inner + assert inner["properties"]["x"]["type"] == "string" + + +def test_no_tools_is_a_noop(): + """Requests without tools are unaffected.""" + result = AnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-sonnet-4-5", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params={"max_tokens": 1024}, + litellm_params={}, + headers={}, + ) + assert "tools" not in result or result.get("tools") is None