fix: add additionalProperties: false for OpenAI strict mode in Anthropic adapter

When translating Anthropic output_format to OpenAI response_format,
the adapter sets strict: true but didn't add additionalProperties: false,
which OpenAI requires at every object nesting level. This caused
BadRequestError for structured output requests routed to OpenAI models.

Fixes #20997
This commit is contained in:
Chesars
2026-03-18 22:32:23 -03:00
parent 2405e0d400
commit 4bd7bdcf43
2 changed files with 138 additions and 0 deletions
@@ -1,3 +1,4 @@
import copy
import hashlib
import json
from typing import (
@@ -824,6 +825,11 @@ class LiteLLMAnthropicMessagesAdapter:
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",
@@ -834,6 +840,37 @@ class LiteLLMAnthropicMessagesAdapter:
},
}
@staticmethod
def _add_additional_properties_false(schema: dict) -> None:
"""
Recursively add 'additionalProperties': false to all object schemas.
OpenAI's strict mode requires this at every object nesting level.
"""
if not isinstance(schema, dict):
return
if schema.get("type") == "object" and "properties" in schema:
schema["additionalProperties"] = False
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 _add_system_message_to_messages(
self,
new_messages: List[AllMessageValues],
@@ -1984,3 +1984,104 @@ def test_translate_anthropic_to_openai_with_mixed_tools():
# tool_name_mapping should be empty for short tool names
assert tool_name_mapping == {}
class TestTranslateAnthropicOutputFormatToOpenAI:
"""Tests for translate_anthropic_output_format_to_openai adding additionalProperties: false."""
def setup_method(self):
self.adapter = LiteLLMAnthropicMessagesAdapter()
def test_simple_object_adds_additional_properties_false(self):
output_format = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
}
result = self.adapter.translate_anthropic_output_format_to_openai(output_format)
assert result is not None
schema = result["json_schema"]["schema"]
assert schema["additionalProperties"] is False
def test_nested_objects_adds_additional_properties_false(self):
output_format = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"name": {"type": "string"},
"address": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
},
},
}
result = self.adapter.translate_anthropic_output_format_to_openai(output_format)
assert result is not None
schema = result["json_schema"]["schema"]
assert schema["additionalProperties"] is False
assert schema["properties"]["user"]["additionalProperties"] is False
assert schema["properties"]["user"]["properties"]["address"]["additionalProperties"] is False
def test_array_items_object_adds_additional_properties_false(self):
output_format = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {"id": {"type": "integer"}},
},
}
},
},
}
result = self.adapter.translate_anthropic_output_format_to_openai(output_format)
assert result is not None
schema = result["json_schema"]["schema"]
assert schema["additionalProperties"] is False
assert schema["properties"]["items"]["items"]["additionalProperties"] is False
def test_does_not_mutate_original_schema(self):
original_schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
}
output_format = {"type": "json_schema", "schema": original_schema}
self.adapter.translate_anthropic_output_format_to_openai(output_format)
assert "additionalProperties" not in original_schema
def test_defs_adds_additional_properties_false(self):
output_format = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {"ref": {"$ref": "#/$defs/Item"}},
"$defs": {
"Item": {
"type": "object",
"properties": {"value": {"type": "string"}},
}
},
},
}
result = self.adapter.translate_anthropic_output_format_to_openai(output_format)
assert result is not None
schema = result["json_schema"]["schema"]
assert schema["$defs"]["Item"]["additionalProperties"] is False
def test_invalid_output_format_returns_none(self):
assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None