fix(anthropic): close strict JSON schemas on the Messages to Responses bridge (#51337)

* fix(anthropic): close strict JSON schemas on the Messages to Responses bridge

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* perf(anthropic): copy each strict schema subtree once

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(anthropic): close nullable objects in strict JSON schemas

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kenneth-berri 2026-11-11 22:15:14 -08:00 • committed by GitHub
parent 7c7e02b06f
commit 4cd8f9174d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 167 additions and 68 deletions

View file

@ -1,4 +1,3 @@
import copy
import hashlib
import json
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
@ -20,6 +19,8 @@ from litellm.llms.anthropic.messages.convertible_blocks import tool_result_text_
from litellm.llms.anthropic.messages.utils import (
is_reasoning_auto_summary_enabled,
json_copy_or_original,
object_mapping,
openai_strict_json_schema,
prompt_cache_key_from_user_id,
)
from litellm.responses.utils import auto_reasoning_summary
@ -1017,66 +1018,23 @@ class LiteLLMAnthropicMessagesAdapter:
Returns:
OpenAI-compatible response_format dict, or None if invalid
"""
if not isinstance(output_format, dict):
output_format_object: Final = object_mapping(output_format)
if output_format_object is None or output_format_object.get("type") != "json_schema":
return None
output_type: Final = output_format.get("type")
if output_type != "json_schema":
return None
schema = output_format.get("schema")
schema: Final = output_format_object.get("schema")
if not schema:
return None
# Deep copy to avoid mutating the original schema
schema = copy.deepcopy(schema)
# OpenAI strict mode requires additionalProperties: false on every object
self._add_additional_properties_false(schema)
# Convert to OpenAI response_format structure
return {
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": schema,
"schema": openai_strict_json_schema(schema),
"strict": True,
},
}
@staticmethod
def _add_additional_properties_false(schema: dict) -> None:
"""
Recursively ensure object schemas comply with OpenAI strict mode.
OpenAI's strict mode requires:
1. 'additionalProperties': false at every object nesting level
2. All property keys listed in 'required'
"""
if not isinstance(schema, dict):
return
if schema.get("type") == "object" and "properties" in schema:
schema["additionalProperties"] = False
schema["required"] = list(schema["properties"].keys())
for prop in schema["properties"].values():
LiteLLMAnthropicMessagesAdapter._add_additional_properties_false(prop)
# Handle array items
if "items" in schema:
LiteLLMAnthropicMessagesAdapter._add_additional_properties_false(schema["items"])
# Handle anyOf/oneOf/allOf
for key in ("anyOf", "oneOf", "allOf"):
if key in schema:
for sub_schema in schema[key]:
LiteLLMAnthropicMessagesAdapter._add_additional_properties_false(sub_schema)
# Handle $defs / definitions
for key in ("$defs", "definitions"):
if key in schema:
for def_schema in schema[key].values():
LiteLLMAnthropicMessagesAdapter._add_additional_properties_false(def_schema)
def _translate_midturn_system_message_to_openai(
self,
message: AnthropicMessagesSystemMessageParam,

View file

@ -35,6 +35,8 @@ from litellm.llms.anthropic.messages.native.utils import (
from litellm.llms.anthropic.messages.utils import (
is_reasoning_auto_summary_enabled,
json_copy_or_original,
object_mapping,
openai_strict_json_schema,
prompt_cache_key_from_user_id,
)
from litellm.types.llms.anthropic import (
@ -588,10 +590,8 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
)
@staticmethod
def _resolve_output_format_strict(strict: bool | None) -> bool:
if strict is None:
return True
return strict
def _resolve_output_format_strict(strict: object) -> bool:
return True if strict is None else bool(strict)
def translate_request(
self,
@ -690,19 +690,23 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
# output_format / output_config.format -> text format
# output_format: {"type": "json_schema", "schema": {...}}
# output_config: {"format": {"type": "json_schema", "schema": {...}}}
output_format: object = anthropic_request.get("output_format")
output_config = anthropic_request.get("output_config")
if not isinstance(output_format, dict) and isinstance(output_config, dict):
output_format = output_config.get("format")
if isinstance(output_format, dict) and output_format.get("type") == "json_schema":
output_config_object: Final = object_mapping(anthropic_request.get("output_config"))
top_level_output_format: Final = object_mapping(anthropic_request.get("output_format"))
output_format: Final = (
top_level_output_format
if top_level_output_format is not None or output_config_object is None
else object_mapping(output_config_object.get("format"))
)
if output_format is not None and output_format.get("type") == "json_schema":
schema: Final = output_format.get("schema")
if schema:
output_strict: Final = self._resolve_output_format_strict(output_format.get("strict"))
responses_kwargs["text"] = {
"format": {
"type": "json_schema",
"name": "structured_output",
"schema": schema,
"strict": self._resolve_output_format_strict(output_format.get("strict")),
"schema": openai_strict_json_schema(schema) if output_strict else schema,
"strict": output_strict,
}
}

View file

@ -1,6 +1,7 @@
import copy
import json
import os
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, TypeVar
@ -37,6 +38,61 @@ def json_copy_or_original(value: _JsonValue) -> _JsonValue:
return value
_SUBSCHEMA_LIST_KEYWORDS: Final = ("anyOf", "oneOf", "allOf")
_SUBSCHEMA_MAP_KEYWORDS: Final = ("$defs", "definitions")
_JSON_ARRAY: Final = TypeAdapter(list[object])
def _json_array(value: object) -> Sequence[object] | None:
try:
return _JSON_ARRAY.validate_python(value, strict=True)
except ValidationError:
return None
def openai_strict_json_schema(schema: object) -> object:
"""New copy of a JSON schema where every object with properties forbids extra keys and requires all of them.
OpenAI strict structured outputs rejects any schema that leaves an object open or a property optional.
"""
source: Final = object_mapping(schema)
if source is None:
return copy.deepcopy(schema)
properties: Final = object_mapping(source.get("properties"))
closing: Final[Mapping[str, object]] = (
{"required": list(properties), "additionalProperties": False}
if properties is not None and _is_object_type(source.get("type"))
else {}
)
return {**{key: _strict_subschema(key, value) for key, value in source.items()}, **closing}
def _is_object_type(schema_type: object) -> bool:
"""Whether a ``type`` keyword names an object, alone or in a union such as ``["object", "null"]``."""
types: Final = _json_array(schema_type)
return schema_type == "object" if types is None else "object" in types
def _strict_subschema(key: str, value: object) -> object:
if key == "items":
return openai_strict_json_schema(value)
if key == "properties" or key in _SUBSCHEMA_MAP_KEYWORDS:
named: Final = object_mapping(value)
return (
copy.deepcopy(value)
if named is None
else {name: openai_strict_json_schema(subschema) for name, subschema in named.items()}
)
if key in _SUBSCHEMA_LIST_KEYWORDS:
variants: Final = _json_array(value)
return (
copy.deepcopy(value) if variants is None else [openai_strict_json_schema(variant) for variant in variants]
)
return copy.deepcopy(value)
class _ClaudeCodeUserId(LiteLLMBaseModel):
"""The JSON Claude Code packs into ``metadata.user_id``; only ``session_id`` is per conversation."""
@ -136,7 +192,7 @@ def proxy_sub_call_logging_kwargs(parent_kwargs: Mapping[str, object]) -> ProxyS
if disable_header is None
else {**parent_params, "proxy_server_request": {"headers": {X_LITELLM_DISABLE_CALLBACKS: disable_header}}}
)
trusted_vars: Final = _object_mapping(parent_kwargs.get(TRUSTED_CALLBACK_VARS_FIELD))
trusted_vars: Final = object_mapping(parent_kwargs.get(TRUSTED_CALLBACK_VARS_FIELD))
if trusted_vars is None:
return dynamic_params
return {**dynamic_params, "litellm_trusted_callback_vars": trusted_vars}
@ -154,13 +210,13 @@ def proxy_sub_call_trace_id(parent_kwargs: Mapping[str, object]) -> str | None:
def _disable_callbacks_header(parent_kwargs: Mapping[str, object]) -> str | None:
parent_request: Final = _object_mapping(parent_kwargs.get("proxy_server_request"))
headers: Final = _object_mapping(parent_request.get("headers")) if parent_request is not None else None
parent_request: Final = object_mapping(parent_kwargs.get("proxy_server_request"))
headers: Final = object_mapping(parent_request.get("headers")) if parent_request is not None else None
header: Final = headers.get(X_LITELLM_DISABLE_CALLBACKS) if headers is not None else None
return header if isinstance(header, str) else None
def _object_mapping(value: object) -> Mapping[str, object] | None:
def object_mapping(value: object) -> Mapping[str, object] | None:
try:
return _OBJECT_MAPPING.validate_python(value)
except ValidationError:

View file

@ -3,6 +3,7 @@ Tests for LiteLLMAnthropicToResponsesAPIAdapter
(litellm/llms/anthropic/messages/responses_adapters/transformation.py)
"""
import copy
import json
import os
from collections.abc import Mapping
@ -217,8 +218,8 @@ class TestOutputConfigStructuredOutput:
assert kwargs["text"]["format"]["schema"] == self._SCHEMA
assert kwargs["text"]["format"]["strict"] is False
def test_optional_property_stays_out_of_required_list(self):
"""A property absent from required must stay absent from required in the translated schema."""
def test_optional_property_stays_out_of_required_list_when_not_strict(self):
"""Without strict, a property absent from required stays absent from required in the translated schema."""
schema = {
"type": "object",
"properties": {
@ -228,7 +229,7 @@ class TestOutputConfigStructuredOutput:
"required": ["name"],
"additionalProperties": False,
}
req = _make_request(output_format={"type": "json_schema", "schema": schema})
req = _make_request(output_format={"type": "json_schema", "schema": schema, "strict": False})
kwargs = _ADAPTER.translate_request(req)
fmt_schema = kwargs["text"]["format"]["schema"]
assert fmt_schema["required"] == ["name"]
@ -247,6 +248,79 @@ class TestOutputConfigStructuredOutput:
assert output_format == snapshot
assert req["output_format"] == snapshot
@pytest.mark.parametrize("format_field", ("output_format", "output_config"))
@pytest.mark.parametrize("strict_options", ({}, {"strict": True}))
def test_strict_schema_closes_every_object_like_messages_to_chat_adapter(
self,
format_field: Literal["output_format", "output_config"],
strict_options: Mapping[str, bool],
) -> None:
"""Regression for #51200: strict output schemas must be closed at every nesting level."""
def open_object(properties: Mapping[str, object], required: list[str]) -> dict[str, object]:
return {"type": "object", "properties": properties, "required": required, "additionalProperties": True}
def closed_object(properties: Mapping[str, object]) -> dict[str, object]:
return {
"type": "object",
"properties": properties,
"required": list(properties),
"additionalProperties": False,
}
schema: Final = {
**open_object(
{
"entry": open_object({"value": {"type": "string"}, "note": {"type": "string"}}, ["value"]),
"rows": {"type": "array", "items": open_object({"id": {"type": "integer"}}, [])},
"choice": {"anyOf": [open_object({"a": {"type": "string"}}, []), {"type": "null"}]},
"ref": {"$ref": "#/$defs/Item"},
"maybe": {**open_object({"flag": {"type": "boolean"}}, []), "type": ["object", "null"]},
},
["entry"],
),
"$defs": {"Item": open_object({"label": {"type": "string"}}, [])},
}
output_format: Final = {"type": "json_schema", "schema": schema, **strict_options}
snapshot: Final = copy.deepcopy(output_format)
request: Final = _make_request(
**{format_field: output_format if format_field == "output_format" else {"format": output_format}}
)
expected_schema: Final = {
**closed_object(
{
"entry": closed_object({"value": {"type": "string"}, "note": {"type": "string"}}),
"rows": {"type": "array", "items": closed_object({"id": {"type": "integer"}})},
"choice": {"anyOf": [closed_object({"a": {"type": "string"}}), {"type": "null"}]},
"ref": {"$ref": "#/$defs/Item"},
"maybe": {**closed_object({"flag": {"type": "boolean"}}), "type": ["object", "null"]},
}
),
"$defs": {"Item": closed_object({"label": {"type": "string"}})},
}
translated: Final = _ADAPTER.translate_request(request)
chat, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(request)
assert translated["text"] == {
"format": {"type": "json_schema", "name": "structured_output", "schema": expected_schema, "strict": True}
}
assert chat.get("response_format") == {
"type": "json_schema",
"json_schema": {"name": "structured_output", "schema": expected_schema, "strict": True},
}
assert output_format == snapshot
def test_non_strict_schema_is_forwarded_unchanged(self) -> None:
schema: Final = {
"type": "object",
"properties": {"entry": {"type": "object", "properties": {"value": {"type": "string"}}}},
"additionalProperties": True,
}
request: Final = _make_request(output_format={"type": "json_schema", "schema": schema, "strict": False})
assert _ADAPTER.translate_request(request)["text"]["format"]["schema"] == schema
# ---------------------------------------------------------------------------
# translate_messages_to_responses_input

View file

@ -301,7 +301,14 @@ async def test_anthropic_format_counts_the_structured_output_schema(monkeypatch:
assert sent == {
"model": "gpt-4o",
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Answer in JSON."}]}],
"text": {"format": {"type": "json_schema", "name": "structured_output", "schema": schema, "strict": True}},
"text": {
"format": {
"type": "json_schema",
"name": "structured_output",
"schema": {**schema, "required": ["answer"], "additionalProperties": False},
"strict": True,
}
},
}