mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
71e69d3485
commit
ea7671e96f
2 changed files with 150 additions and 0 deletions
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue