fix(anthropic): coerce tool input_schema.type to "object" on /v1/messages pass-through

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
This commit is contained in:
Sovit 2026-06-27 14:26:10 -07:00
parent 71e69d3485
commit ea7671e96f
2 changed files with 150 additions and 0 deletions

View file

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

View file

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