mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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:
parent
7c7e02b06f
commit
4cd8f9174d
5 changed files with 167 additions and 68 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue