From 230960594b58ab1c47b5336fb5dded9979b87c09 Mon Sep 17 00:00:00 2001 From: adhavan Date: Mon, 15 Jun 2026 13:56:38 +0530 Subject: [PATCH 1/3] fix: guard against IndexError/ValueError when dict annotation has no type args `_transform_recursive` and its async counterpart (in `_utils/_transform.py`) unconditionally did `get_args(stripped_type)[1]` for any `origin == dict` branch. When the annotation is a bare, unparameterised `dict` (no `[K, V]` type arguments), `get_args(dict)` returns an empty tuple, so the index access raises `IndexError: tuple index out of range`. `construct_type` in `_models.py` had the same assumption, unpacking `get_args(type_)` into exactly two targets with `_, items_type = ...`, which raises `ValueError: not enough values to unpack (expected 2, got 0)` for a bare `dict`. Fix: in both sites, check `len(args) >= 2` before indexing. A bare `dict` annotation carries no value-type information, so returning the mapping unchanged (rather than trying to recurse) is the correct behaviour. Adds four regression tests. Closes #3338, #3341 --- src/openai/_models.py | 6 +++++- src/openai/_utils/_transform.py | 16 ++++++++++++---- tests/test_models.py | 13 +++++++++++++ tests/test_transform.py | 22 ++++++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/openai/_models.py b/src/openai/_models.py index ed4c1f82d6..73457ed28d 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -657,7 +657,11 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_mapping(value): return value - _, items_type = get_args(type_) # Dict[_, items_type] + type_args = get_args(type_) # Dict[_, items_type] + if len(type_args) < 2: + # bare `dict` with no type parameters — return the mapping as-is + return value + _, items_type = type_args return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} if ( diff --git a/src/openai/_utils/_transform.py b/src/openai/_utils/_transform.py index 414f38c340..ca9e980d14 100644 --- a/src/openai/_utils/_transform.py +++ b/src/openai/_utils/_transform.py @@ -180,8 +180,12 @@ def _transform_recursive( return _transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] - return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + args = get_args(stripped_type) + if len(args) >= 2: + items_type = args[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + # bare `dict` with no type parameters — return as-is + return data if ( # List[T] @@ -346,8 +350,12 @@ async def _async_transform_recursive( return await _async_transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] - return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + args = get_args(stripped_type) + if len(args) >= 2: + items_type = args[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + # bare `dict` with no type parameters — return as-is + return data if ( # List[T] diff --git a/tests/test_models.py b/tests/test_models.py index cc204bac1d..4157aecab9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1015,3 +1015,16 @@ class Model(BaseModel): # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) assert m.data["items"] == ["h", "e", "l", "l", "o"] assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"] + + +def test_construct_type_bare_dict_does_not_crash() -> None: + """construct_type must not raise ValueError when type_ is bare dict (issue #3341).""" + result = construct_type(value={"key": "value"}, type_=dict) + assert result == {"key": "value"} + + +def test_construct_type_parameterised_dict_still_works() -> None: + """dict[str, str] must still work after the bare-dict guard.""" + from typing import Dict + result = construct_type(value={"k": "v"}, type_=Dict[str, str]) + assert result == {"k": "v"} diff --git a/tests/test_transform.py b/tests/test_transform.py index bece75dfc7..42ec1c7754 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -458,3 +458,25 @@ async def test_strips_notgiven(use_async: bool) -> None: async def test_strips_omit(use_async: bool) -> None: assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} assert await transform({"foo_bar": omit}, Foo1, use_async) == {} + + +class TestBareDict: + def test_transform_bare_dict_does_not_crash(self) -> None: + """Bare `dict` annotation (no type args) must not raise IndexError (issues #3338 / #3341).""" + from typing import TypedDict + + class Params(TypedDict, total=False): + metadata: dict # bare, unparameterised dict + + result = _transform({"metadata": {"k": "v"}}, Params) + assert result == {"metadata": {"k": "v"}} + + def test_transform_parameterised_dict_still_works(self) -> None: + """dict[str, str] annotation must still recurse into values.""" + from typing import Dict, TypedDict + + class Params(TypedDict, total=False): + tags: Dict[str, str] + + result = _transform({"tags": {"a": "b"}}, Params) + assert result == {"tags": {"a": "b"}} From 336d95d548d56ef70d34416bb81277d3c6b63746 Mon Sep 17 00:00:00 2001 From: adhavan Date: Sun, 2 Aug 2026 20:09:43 +0530 Subject: [PATCH 2/3] Drop the redundant local Dict import in the transform tests ruff was failing the lint job with F401: the test imports Dict inside the function, but typing.Dict is already imported at module scope, so the local one shadows it for no reason. The annotation still resolves and both dict tests pass. --- tests/test_transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_transform.py b/tests/test_transform.py index 42ec1c7754..0ba5d492a3 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -473,7 +473,7 @@ class Params(TypedDict, total=False): def test_transform_parameterised_dict_still_works(self) -> None: """dict[str, str] annotation must still recurse into values.""" - from typing import Dict, TypedDict + from typing import TypedDict class Params(TypedDict, total=False): tags: Dict[str, str] From 1cb3ded82f03a1ad11b5f055e29fab87cc0e7253 Mon Sep 17 00:00:00 2001 From: adhavan Date: Sun, 2 Aug 2026 23:04:56 +0530 Subject: [PATCH 3/3] Silence both type checkers on the deliberately bare dict annotation The bare `dict` is the whole point of TestBareDict, so mypy's type-arg error and pyright's reportMissingTypeArgument are both expected here. Suppressed on that line with a comment, rather than parameterising the annotation and losing what the test covers. --- tests/test_transform.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_transform.py b/tests/test_transform.py index 0ba5d492a3..ee8f79a8e0 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -466,7 +466,9 @@ def test_transform_bare_dict_does_not_crash(self) -> None: from typing import TypedDict class Params(TypedDict, total=False): - metadata: dict # bare, unparameterised dict + # the bare annotation is the point of the test, so pyright's + # request for type arguments is silenced deliberately + metadata: dict # type: ignore[type-arg] # pyright: ignore[reportMissingTypeArgument] result = _transform({"metadata": {"k": "v"}}, Params) assert result == {"metadata": {"k": "v"}}