From fca1aaa9b5b6790d62b07bf2b0a88c4d1cec30a9 Mon Sep 17 00:00:00 2001 From: Yu Zhong Date: Thu, 11 Sep 2025 23:02:12 +0800 Subject: [PATCH] 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 --- .../langchain_core/utils/function_calling.py | 10 ++++- .../unit_tests/utils/test_function_calling.py | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 801aacc727a..b36db207ed6 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -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 diff --git a/libs/core/tests/unit_tests/utils/test_function_calling.py b/libs/core/tests/unit_tests/utils/test_function_calling.py index f75ae304937..348cce31686 100644 --- a/libs/core/tests/unit_tests/utils/test_function_calling.py +++ b/libs/core/tests/unit_tests/utils/test_function_calling.py @@ -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