fix(core): force overwrite additionalProperties to False in strict mode (#32879)

# Description
This PR fixes a bug in _recursive_set_additional_properties_false used
in function_calling.convert_to_openai_function.

Previously, schemas with "additionalProperties=True" were not correctly
overridden when strict validation was expected, which could lead to
invalid OpenAI function schemas.

The updated implementation ensures that:
- Any schema with "additionalProperties" already set will now be forced
to False under strict mode.
- Recursive traversal of properties, items, and anyOf is preserved.
- Function signature remains unchanged for backward compatibility.

# Issue
When using tool calling in OpenAI structured output strict mode
(strict=True), 400: "Invalid schema for response_format XXXXX
'additionalProperties' is required to be supplied and to be false" error
raises for the parameter that contains dict type. OpenAI requires
additionalProperties to be set to False.
Some PRs try to resolved the issue.
- PR #25169 introduced _recursive_set_additional_properties_false to
recursively set additionalProperties=False.
- PR #26287 fixed handling of empty parameter tools for OpenAI function
generation.
- PR #30971 added support for Union type arguments in strict mode of
OpenAI function calling / structured output.

Despite these improvements, since Pydantic 2.11, it will always add
`additionalProperties: True` for arbitrary dictionary schemas dict or
Any (https://pydantic.dev/articles/pydantic-v2-11-release#changes).
Schemas that already had additionalProperties=True in such cases were
not being overridden, which this PR addresses to ensure strict mode
behaves correctly in all cases.

# Dependencies
No Changes

---------

Co-authored-by: Zhong, Yu <yzhong@freewheel.com>
This commit is contained in:
Yu Zhong
2025-09-11 23:02:12 +08:00
committed by GitHub
parent af17774186
commit fca1aaa9b5
2 changed files with 50 additions and 2 deletions

View File

@@ -833,8 +833,14 @@ def _recursive_set_additional_properties_false(
if isinstance(schema, dict):
# Check if 'required' is a key at the current level or if the schema is empty,
# in which case additionalProperties still needs to be specified.
if "required" in schema or (
"properties" in schema and not schema["properties"]
if (
"required" in schema
or ("properties" in schema and not schema["properties"])
# Since Pydantic 2.11, it will always add `additionalProperties: True`
# for arbitrary dictionary schemas
# See: https://pydantic.dev/articles/pydantic-v2-11-release#changes
# If it is already set to True, we need override it to False
or "additionalProperties" in schema
):
schema["additionalProperties"] = False

View File

@@ -22,6 +22,10 @@ try:
except ImportError:
TypingAnnotated = ExtensionsAnnotated
from importlib.metadata import version
from packaging.version import parse
from pydantic import BaseModel, Field
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@@ -1122,3 +1126,41 @@ def test_convert_to_json_schema(
):
actual = convert_to_json_schema(fn)
assert actual == expected
def test_convert_to_openai_function_nested_strict_2() -> None:
def my_function(arg1: dict, arg2: Union[dict, None]) -> None:
"""Dummy function."""
expected: dict = {
"name": "my_function",
"description": "Dummy function.",
"parameters": {
"type": "object",
"properties": {
"arg1": {
"additionalProperties": False,
"type": "object",
},
"arg2": {
"anyOf": [
{"additionalProperties": False, "type": "object"},
{"type": "null"},
],
},
},
"required": ["arg1", "arg2"],
"additionalProperties": False,
},
"strict": True,
}
# there will be no extra `"additionalProperties": False` when Pydantic < 2.11
if parse(version("pydantic")) < parse("2.11"):
del expected["parameters"]["properties"]["arg1"]["additionalProperties"]
del expected["parameters"]["properties"]["arg2"]["anyOf"][0][
"additionalProperties"
]
actual = convert_to_openai_function(my_function, strict=True)
assert actual == expected