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"}}