fix(anthropic): keep union and $ref tool schemas from being emptied

A tool whose parameters are a Pydantic Union[A, B] reached Anthropic taking no
arguments at all. Pydantic puts anyOf over $defs at the root with no type or
properties, so the sanitizer stamped type: "object" over it, invented an empty
properties, and then dropped anyOf with its allowlist. The $defs glossary still
went on the wire, paid for and referenced by nothing. Nothing raised: Anthropic
accepts a tool with no arguments, so the model simply stopped calling it. Root
oneOf, allOf and a bare $ref failed the same way.

Resolve a root-level local $ref, then merge root combinators, before the
allowlist runs. The merging reuses flatten_top_level_schema_combinators, which
OpenAI, Azure and OpenAI Responses already put tool schemas through, so this
adopts an existing tradeoff rather than inventing one: branches merge, and
required narrows to what every branch demands.

Schemas that already worked are untouched, including a combinator nested inside
a property, which Anthropic accepts. An external, missing or non-object root
$ref is left exactly as before rather than gaining a new failure mode.

Fixes #43157
This commit is contained in:
ajitsharmas2007 2026-09-27 19:35:40 +05:30
parent 491d454826
commit 1ce03b290d
2 changed files with 154 additions and 1 deletions

View file

@ -1140,10 +1140,17 @@ def sanitize_input_schema_for_anthropic(input_schema: dict) -> "AnthropicInputSc
(``AnthropicConfig._map_tool_helper``) and Anthropic Messages MCP paths run
a schema through here so an external MCP schema cannot succeed on one route
and 400 on the other.
A root that is a local ``$ref`` or a top-level combinator is resolved into an
object first. Without that, ``type: "object"`` and an empty ``properties``
are stamped over a schema whose fields live one level down, and the
allowlist then drops the combinator: a ``TypeAdapter(Union[A, B])`` tool
reaches Anthropic taking no arguments at all. An unresolvable root (external
or missing ``$ref``, a branch that cannot merge) is left alone.
"""
from litellm.types.llms.anthropic import AnthropicInputSchema
normalized = dict(input_schema) if input_schema else {}
normalized = dict(flatten_top_level_schema_combinators(_inline_root_schema_ref(input_schema or {})))
if normalized.get("type") != "object":
normalized["type"] = "object"
if "properties" not in normalized:
@ -1204,6 +1211,25 @@ def _resolve_local_schema_ref(root: Mapping[str, object], ref: str) -> Mapping[s
return target if isinstance(target, dict) else None
def _inline_root_schema_ref(schema: Mapping[str, object]) -> Mapping[str, object]:
"""Replace a root-level local ``$ref`` with the schema it points at.
The glossaries are carried over so refs inside the target still resolve, and
the target wins on any key it shares with the root. A root without a ``$ref``,
or one pointing outside the document or at a missing name, is returned as is.
"""
ref: Final = schema.get("$ref")
if not isinstance(ref, str):
return schema
target: Final = _resolve_local_schema_ref(schema, ref)
if target is None:
return schema
glossaries: Final = MappingProxyType(
{container: schema[container] for _, container in _LOCAL_SCHEMA_REF_PREFIXES if container in schema}
)
return MappingProxyType({**glossaries, **target})
def _mergeable_branch(
root: Mapping[str, object],
branch: object,

View file

@ -1990,3 +1990,130 @@ class TestMergeConsecutiveSystemMessages:
)
assert merged == [{"role": "system"}, {"role": "user", "content": "Hi"}]
class TestSanitizeInputSchemaForAnthropic:
"""Anthropic rejects a tool schema whose root is a union or a ``$ref``, so the
sanitizer coerces one into an object. Stamping ``type: "object"`` and an empty
``properties`` over a root whose fields live one level down, then dropping the
combinator with the allowlist, sent a ``TypeAdapter(Union[A, B])`` tool to
Anthropic taking no arguments at all (#43157)."""
A: Final = {
"type": "object",
"properties": {"kind": {"type": "string"}, "a": {"type": "string"}},
"required": ["kind", "a"],
}
B: Final = {
"type": "object",
"properties": {"kind": {"type": "string"}, "b": {"type": "integer"}},
"required": ["kind", "b"],
}
def _union_root(self, combinator):
return {"$defs": {"A": self.A, "B": self.B}, combinator: [{"$ref": "#/$defs/A"}, {"$ref": "#/$defs/B"}]}
@pytest.mark.parametrize("combinator", ["anyOf", "oneOf"])
def test_union_root_keeps_every_branch_field(self, combinator):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
sanitize_input_schema_for_anthropic,
)
result = sanitize_input_schema_for_anthropic(self._union_root(combinator))
assert sorted(result["properties"]) == [
"a",
"b",
"kind",
], f"a {combinator} root must keep its branches' fields, got {dict(result)}"
def test_ref_root_resolves_to_the_schema_it_points_at(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
sanitize_input_schema_for_anthropic,
)
result = sanitize_input_schema_for_anthropic({"$defs": {"A": self.A}, "$ref": "#/$defs/A"})
assert sorted(result["properties"]) == ["a", "kind"], f"a $ref root must inline its target, got {dict(result)}"
assert sorted(result["required"]) == ["a", "kind"], (
f"the target's required list must survive, got {dict(result)}"
)
def test_union_required_narrows_to_the_fields_every_branch_demands(self):
"""Merging branches is lossy by design: only what all of them require stays
required, which is the tradeoff OpenAI and Azure already accept via the
shared flattening helper."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
sanitize_input_schema_for_anthropic,
)
result = sanitize_input_schema_for_anthropic(self._union_root("anyOf"))
assert sorted(result.get("required", [])) == ["kind"], (
f"only the field both branches require stays required, got {dict(result)}"
)
@pytest.mark.parametrize(
"name, schema, expected_properties",
[
("plain object", {"type": "object", "properties": {"x": {"type": "string"}}}, ["x"]),
("no-arg tool", {"type": "object", "properties": {}}, []),
("empty schema", {}, []),
("external $ref root", {"$ref": "https://example.com/schema.json"}, []),
("missing $ref target", {"$defs": {}, "$ref": "#/$defs/Nope"}, []),
],
)
def test_shapes_the_sanitizer_already_handled_are_untouched(self, name, schema, expected_properties):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
sanitize_input_schema_for_anthropic,
)
result = sanitize_input_schema_for_anthropic(schema)
assert sorted(result["properties"]) == expected_properties, f"{name} should be unchanged, got {dict(result)}"
assert result["type"] == "object", f"{name} must still be coerced to an object, got {dict(result)}"
def test_a_combinator_nested_inside_a_property_is_left_alone(self):
"""Anthropic accepts nested combinators; only the root shape is a problem."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
sanitize_input_schema_for_anthropic,
)
nested = {"anyOf": [{"type": "string"}, {"type": "integer"}]}
result = sanitize_input_schema_for_anthropic({"type": "object", "properties": {"x": nested}})
assert result["properties"]["x"] == nested, f"a nested union must survive verbatim, got {dict(result)}"
def test_a_pydantic_union_tool_reaches_anthropic_with_its_arguments(self):
"""The reporter's path: Pydantic emits a root ``anyOf`` over ``$defs``."""
from typing import Literal
from pydantic import BaseModel, TypeAdapter
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
class Alpha(BaseModel):
kind: Literal["a"]
a: str
class Beta(BaseModel):
kind: Literal["b"]
b: int
tool = {
"type": "function",
"function": {
"name": "record",
"description": "record an event",
"parameters": TypeAdapter(Alpha | Beta).json_schema(),
},
}
mapped, _ = AnthropicConfig()._map_tools([tool])
assert sorted(mapped[0]["input_schema"]["properties"]) == [
"a",
"b",
"kind",
], f"the tool must reach Anthropic with arguments, got {mapped[0]['input_schema']}"