From 701552b05c7d179f8fc692697c1d6671d742cea4 Mon Sep 17 00:00:00 2001 From: Abhinavexists Date: Tue, 25 Nov 2025 01:43:35 +0530 Subject: [PATCH 1/5] fix NotRequired in TypedDict causes TypeError when converting to OpenAI function schema --- .../langchain_core/utils/function_calling.py | 15 ++++++++++++++- .../unit_tests/utils/test_function_calling.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 18373e3b3b81f..3b7e8ec9c3c8f 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -19,6 +19,7 @@ get_origin, ) +from httpcore import Origin from pydantic import BaseModel from pydantic.v1 import BaseModel as BaseModelV1 from pydantic.v1 import Field as Field_v1 @@ -237,6 +238,11 @@ def _convert_any_typed_dicts_to_pydantic( ) fields: dict = {} for arg, arg_type in annotations_.items(): + origin = get_origin(arg_type) + is_not_required = origin is typing.NotRequired + if origin in {typing.NotRequired, typing.Required}: + arg_type = get_args(arg_type)[0] + if get_origin(arg_type) is Annotated: # type: ignore[comparison-overlap] annotated_args = get_args(arg_type) new_arg_type = _convert_any_typed_dicts_to_pydantic( @@ -256,12 +262,15 @@ def _convert_any_typed_dicts_to_pydantic( raise ValueError(msg) if arg_desc := arg_descriptions.get(arg): field_kwargs["description"] = arg_desc + if is_not_required and "default" not in field_kwargs: + field_kwargs["default"] = None fields[arg] = (new_arg_type, Field_v1(**field_kwargs)) else: new_arg_type = _convert_any_typed_dicts_to_pydantic( arg_type, depth=depth + 1, visited=visited ) - field_kwargs = {"default": ...} + # NotRequired fields should have None as default, required fields use ... + field_kwargs = {"default": None if is_not_required else ...} if arg_desc := arg_descriptions.get(arg): field_kwargs["description"] = arg_desc fields[arg] = (new_arg_type, Field_v1(**field_kwargs)) @@ -269,6 +278,10 @@ def _convert_any_typed_dicts_to_pydantic( model.__doc__ = description visited[typed_dict] = model return model + + if (origin := get_origin(type_)) and origin in {typing.NotRequired, typing.Required}: + return type_ + if (origin := get_origin(type_)) and (type_args := get_args(type_)): subscriptable_origin = _py_38_safe_origin(origin) type_args = tuple( 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 c4edce261be37..d8befbb57469d 100644 --- a/libs/core/tests/unit_tests/utils/test_function_calling.py +++ b/libs/core/tests/unit_tests/utils/test_function_calling.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + NotRequired, TypeAlias, ) from typing import TypedDict as TypingTypedDict @@ -1168,3 +1169,19 @@ class MyModel(BaseModel): func = convert_to_openai_function(MyModel, strict=True) actual = func["parameters"]["required"] assert actual == expected + + +def test_convert_to_openai_function_typed_dict_with_not_required() -> None: + class MyTypedDict(TypingTypedDict): + """A TypedDict with NotRequired field.""" + + required_field: str + optional_field: NotRequired[str] + + result = convert_to_openai_function(MyTypedDict) + + assert result["name"] == "MyTypedDict" + assert "required_field" in result["parameters"]["properties"] + assert "optional_field" in result["parameters"]["properties"] + assert "required_field" in result["parameters"]["required"] + assert "optional_field" not in result["parameters"]["required"] From 0d268811c6ffe84df0b16d151be48486194850a1 Mon Sep 17 00:00:00 2001 From: Abhinavexists Date: Tue, 25 Nov 2025 01:49:19 +0530 Subject: [PATCH 2/5] again.. this auto imports.. sad.. --- libs/core/langchain_core/utils/function_calling.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 3b7e8ec9c3c8f..24b6bcce70e41 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -19,7 +19,6 @@ get_origin, ) -from httpcore import Origin from pydantic import BaseModel from pydantic.v1 import BaseModel as BaseModelV1 from pydantic.v1 import Field as Field_v1 From 1cb07b9b1d90c6952f3edbbc789ac8bf83baeb2d Mon Sep 17 00:00:00 2001 From: Abhinavexists Date: Tue, 25 Nov 2025 02:04:31 +0530 Subject: [PATCH 3/5] fixed typing import for python 3.11 and lint fix --- .../langchain_core/utils/function_calling.py | 20 ++++++++++--------- .../unit_tests/utils/test_function_calling.py | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 24b6bcce70e41..726e942c65a37 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -23,7 +23,7 @@ from pydantic.v1 import BaseModel as BaseModelV1 from pydantic.v1 import Field as Field_v1 from pydantic.v1 import create_model as create_model_v1 -from typing_extensions import TypedDict, is_typeddict +from typing_extensions import NotRequired, Required, TypedDict, is_typeddict import langchain_core from langchain_core._api import beta @@ -238,12 +238,14 @@ def _convert_any_typed_dicts_to_pydantic( fields: dict = {} for arg, arg_type in annotations_.items(): origin = get_origin(arg_type) - is_not_required = origin is typing.NotRequired - if origin in {typing.NotRequired, typing.Required}: - arg_type = get_args(arg_type)[0] + is_not_required = origin is NotRequired + if origin in {NotRequired, Required}: + inner_arg_type = get_args(arg_type)[0] + else: + inner_arg_type = arg_type - if get_origin(arg_type) is Annotated: # type: ignore[comparison-overlap] - annotated_args = get_args(arg_type) + if get_origin(inner_arg_type) is Annotated: # type: ignore[comparison-overlap] + annotated_args = get_args(inner_arg_type) new_arg_type = _convert_any_typed_dicts_to_pydantic( annotated_args[0], depth=depth + 1, visited=visited ) @@ -266,9 +268,9 @@ def _convert_any_typed_dicts_to_pydantic( fields[arg] = (new_arg_type, Field_v1(**field_kwargs)) else: new_arg_type = _convert_any_typed_dicts_to_pydantic( - arg_type, depth=depth + 1, visited=visited + inner_arg_type, depth=depth + 1, visited=visited ) - # NotRequired fields should have None as default, required fields use ... + # NotRequired fields have None as default, required fields use ... field_kwargs = {"default": None if is_not_required else ...} if arg_desc := arg_descriptions.get(arg): field_kwargs["description"] = arg_desc @@ -278,7 +280,7 @@ def _convert_any_typed_dicts_to_pydantic( visited[typed_dict] = model return model - if (origin := get_origin(type_)) and origin in {typing.NotRequired, typing.Required}: + if (origin := get_origin(type_)) and origin in {NotRequired, Required}: return type_ if (origin := get_origin(type_)) and (type_args := get_args(type_)): 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 d8befbb57469d..9df72edb1c9d6 100644 --- a/libs/core/tests/unit_tests/utils/test_function_calling.py +++ b/libs/core/tests/unit_tests/utils/test_function_calling.py @@ -4,7 +4,6 @@ from typing import ( Any, Literal, - NotRequired, TypeAlias, ) from typing import TypedDict as TypingTypedDict @@ -12,6 +11,7 @@ import pytest from pydantic import BaseModel as BaseModelV2Maybe # pydantic: ignore from pydantic import Field as FieldV2Maybe # pydantic: ignore +from typing_extensions import NotRequired from typing_extensions import TypedDict as ExtensionsTypedDict try: From 0a82629ebad8e9f539c13a00dfffc1ec91cadad6 Mon Sep 17 00:00:00 2001 From: Abhinavexists Date: Tue, 25 Nov 2025 02:07:47 +0530 Subject: [PATCH 4/5] lint fix --- libs/core/langchain_core/utils/function_calling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 726e942c65a37..66c049c4b368d 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -244,7 +244,7 @@ def _convert_any_typed_dicts_to_pydantic( else: inner_arg_type = arg_type - if get_origin(inner_arg_type) is Annotated: # type: ignore[comparison-overlap] + if get_origin(inner_arg_type) is Annotated: annotated_args = get_args(inner_arg_type) new_arg_type = _convert_any_typed_dicts_to_pydantic( annotated_args[0], depth=depth + 1, visited=visited From 8e7b021b64d305ee60e3a2253f53d33e5dc61b3c Mon Sep 17 00:00:00 2001 From: Abhinavexists Date: Tue, 25 Nov 2025 02:12:17 +0530 Subject: [PATCH 5/5] lint fix --- libs/core/langchain_core/utils/function_calling.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 66c049c4b368d..b6cd687c7f420 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -238,8 +238,8 @@ def _convert_any_typed_dicts_to_pydantic( fields: dict = {} for arg, arg_type in annotations_.items(): origin = get_origin(arg_type) - is_not_required = origin is NotRequired - if origin in {NotRequired, Required}: + is_not_required = origin is NotRequired # type: ignore[comparison-overlap] + if origin in {NotRequired, Required}: # type: ignore[comparison-overlap] inner_arg_type = get_args(arg_type)[0] else: inner_arg_type = arg_type @@ -280,10 +280,10 @@ def _convert_any_typed_dicts_to_pydantic( visited[typed_dict] = model return model - if (origin := get_origin(type_)) and origin in {NotRequired, Required}: + if (origin := get_origin(type_)) and origin in {NotRequired, Required}: # type: ignore[assignment] return type_ - if (origin := get_origin(type_)) and (type_args := get_args(type_)): + if (origin := get_origin(type_)) and (type_args := get_args(type_)): # type: ignore[assignment] subscriptable_origin = _py_38_safe_origin(origin) type_args = tuple( _convert_any_typed_dicts_to_pydantic(arg, depth=depth + 1, visited=visited)