diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index b6c902872d4..c5c4d82461d 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -389,9 +389,13 @@ def convert_to_openai_function( if strict is not None: oai_function["strict"] = strict - # As of 08/06/24, OpenAI requires that additionalProperties be supplied and set - # to False if strict is True. - oai_function["parameters"]["additionalProperties"] = False + if strict: + # As of 08/06/24, OpenAI requires that additionalProperties be supplied and + # set to False if strict is True. + # All properties layer needs 'additionalProperties=False' + oai_function["parameters"] = _recursive_set_additional_properties_false( + oai_function["parameters"] + ) return oai_function @@ -592,3 +596,20 @@ def _py_38_safe_origin(origin: Type) -> Type: **origin_union_type_map, } return cast(Type, origin_map.get(origin, origin)) + + +def _recursive_set_additional_properties_false( + schema: Dict[str, Any], +) -> Dict[str, Any]: + if isinstance(schema, dict): + # Check if 'required' is a key at the current level + if "required" in schema: + schema["additionalProperties"] = False + # Recursively check 'properties' and 'items' if they exist + if "properties" in schema: + for value in schema["properties"].values(): + _recursive_set_additional_properties_false(value) + if "items" in schema: + _recursive_set_additional_properties_false(schema["items"]) + + return schema 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 dea6d6382cb..ba5ba4c9fa6 100644 --- a/libs/core/tests/unit_tests/utils/test_function_calling.py +++ b/libs/core/tests/unit_tests/utils/test_function_calling.py @@ -341,9 +341,7 @@ def test_convert_to_openai_function_nested() -> None: "required": ["nested_arg1", "nested_arg2"], }, }, - "required": [ - "arg1", - ], + "required": ["arg1"], }, } @@ -351,6 +349,47 @@ def test_convert_to_openai_function_nested() -> None: assert actual == expected +def test_convert_to_openai_function_nested_strict() -> None: + class Nested(BaseModel): + nested_arg1: int = Field(..., description="foo") + nested_arg2: Literal["bar", "baz"] = Field( + ..., description="one of 'bar', 'baz'" + ) + + def my_function(arg1: Nested) -> None: + """dummy function""" + pass + + expected = { + "name": "my_function", + "description": "dummy function", + "parameters": { + "type": "object", + "properties": { + "arg1": { + "type": "object", + "properties": { + "nested_arg1": {"type": "integer", "description": "foo"}, + "nested_arg2": { + "type": "string", + "enum": ["bar", "baz"], + "description": "one of 'bar', 'baz'", + }, + }, + "required": ["nested_arg1", "nested_arg2"], + "additionalProperties": False, + }, + }, + "required": ["arg1"], + "additionalProperties": False, + }, + "strict": True, + } + + actual = convert_to_openai_function(my_function, strict=True) + assert actual == expected + + @pytest.mark.xfail(reason="Pydantic converts Optional[str] to str in .schema()") def test_function_optional_param() -> None: @tool diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py index f1eb6c39a5f..2e235421f93 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py @@ -868,6 +868,43 @@ def test_structured_output_strict( next(chat.stream("Tell me a joke about cats.")) +@pytest.mark.parametrize( + ("model", "method", "strict"), [("gpt-4o-2024-08-06", "json_schema", None)] +) +def test_nested_structured_output_strict( + model: str, method: Literal["json_schema"], strict: Optional[bool] +) -> None: + """Test to verify structured output with strict=True for nested object.""" + + from typing import TypedDict + + llm = ChatOpenAI(model=model, temperature=0) + + class SelfEvaluation(TypedDict): + score: int + text: str + + class JokeWithEvaluation(TypedDict): + """Joke to tell user.""" + + setup: str + punchline: str + self_evaluation: SelfEvaluation + + # Schema + chat = llm.with_structured_output(JokeWithEvaluation, method=method, strict=strict) + result = chat.invoke("Tell me a joke about cats.") + assert isinstance(result, dict) + assert set(result.keys()) == {"setup", "punchline", "self_evaluation"} + assert set(result["self_evaluation"].keys()) == {"score", "text"} + + for chunk in chat.stream("Tell me a joke about cats."): + assert isinstance(chunk, dict) + assert isinstance(chunk, dict) # for mypy + assert set(chunk.keys()) == {"setup", "punchline", "self_evaluation"} + assert set(chunk["self_evaluation"].keys()) == {"score", "text"} + + def test_json_mode() -> None: llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) response = llm.invoke(