This commit is contained in:
Sovit Nayak 2026-09-19 13:05:52 -04:00 committed by GitHub
commit eb7f7aa2cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 150 additions and 0 deletions

View file

@ -528,6 +528,38 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if thinking_enabled or effort_enabled:
optional_params.pop("temperature", None)
@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,
@ -606,6 +638,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: Final = 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