diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index e68360e69b9..660c7c1dbc9 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -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, diff --git a/litellm/llms/anthropic/messages/responses_adapters/transformation.py b/litellm/llms/anthropic/messages/responses_adapters/transformation.py index d7b8592f45c..1d186c8aee4 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/messages/responses_adapters/transformation.py @@ -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, } } diff --git a/litellm/llms/anthropic/messages/utils.py b/litellm/llms/anthropic/messages/utils.py index 95c7914befa..94f81eadb3b 100644 --- a/litellm/llms/anthropic/messages/utils.py +++ b/litellm/llms/anthropic/messages/utils.py @@ -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: diff --git a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py index 660f00cd722..7ef69d4c791 100644 --- a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py @@ -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 diff --git a/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py b/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py index 35b544be31f..d3722cff1d0 100644 --- a/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py +++ b/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py @@ -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, + } + }, }