From 735c25def0181f813265ea3c9f12511b0b3cd8a9 Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Tue, 14 Jul 2026 15:38:58 -0700 Subject: [PATCH] fix(client): use async transform for Dict[str, T] request params The async transform pipeline (`_async_transform_recursive`) recursed into `Dict[str, T]` values by calling the synchronous `_transform_recursive` instead of its async counterpart. Every other recursive call in the async function already uses the async variants, so this was a copy-paste slip. The consequence is that for any `Dict[str, T]`-typed request param whose values carry file/base64 content, the async client reads the file with a blocking `pathlib.Path.read_bytes()` on the event loop (`_format_data`) rather than the non-blocking `anyio.Path(...).read_bytes()` (`_async_format_data`), stalling the loop. Route dict values through `_async_transform_recursive` so async file I/O stays off the event loop, mirroring the list branch just below it. The synchronous `_transform_recursive` dict branch correctly stays sync. Adds a regression test asserting the async file-read path is used (and the blocking sync path is not) when transforming a `Dict[str, T]` value that carries a base64 `Path` field. --- src/openai/_utils/_transform.py | 2 +- tests/test_transform.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/openai/_utils/_transform.py b/src/openai/_utils/_transform.py index 414f38c340..811bfebcf1 100644 --- a/src/openai/_utils/_transform.py +++ b/src/openai/_utils/_transform.py @@ -347,7 +347,7 @@ async def _async_transform_recursive( 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()} + return {key: await _async_transform_recursive(value, annotation=items_type) for key, value in data.items()} if ( # List[T] diff --git a/tests/test_transform.py b/tests/test_transform.py index bece75dfc7..8a28d4eeed 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -434,6 +434,53 @@ async def test_base64_file_input(use_async: bool) -> None: } # type: ignore[comparison-overlap] +@parametrize +@pytest.mark.asyncio +async def test_dict_of_base64_file_input(use_async: bool) -> None: + # a base64 file field nested inside a `Dict[str, T]` value is still transformed correctly + assert await transform({"key": {"foo": SAMPLE_FILE_PATH}}, Dict[str, TypedDictBase64Input], use_async) == { + "key": {"foo": "SGVsbG8sIHdvcmxkIQo="} + } # type: ignore[comparison-overlap] + + +@pytest.mark.asyncio +async def test_async_dict_values_use_async_transform(monkeypatch: pytest.MonkeyPatch) -> None: + # Regression test: the async transform pipeline must recurse into `Dict[str, T]` + # values using the *async* helpers so that file/base64 reads don't block the event + # loop. Previously the async dict branch mistakenly called the synchronous + # `_transform_recursive`, which reads files with a blocking `Path.read_bytes()` + # instead of the non-blocking `anyio.Path(...).read_bytes()`. + import openai._utils._transform as transform_mod + + sync_calls = 0 + async_calls = 0 + + real_format_data = transform_mod._format_data + real_async_format_data = transform_mod._async_format_data + + def counting_format_data(*args: Any, **kwargs: Any) -> object: + nonlocal sync_calls + sync_calls += 1 + return real_format_data(*args, **kwargs) + + async def counting_async_format_data(*args: Any, **kwargs: Any) -> object: + nonlocal async_calls + async_calls += 1 + return await real_async_format_data(*args, **kwargs) + + monkeypatch.setattr(transform_mod, "_format_data", counting_format_data) + monkeypatch.setattr(transform_mod, "_async_format_data", counting_async_format_data) + + result = await _async_transform( + {"key": {"foo": SAMPLE_FILE_PATH}}, + expected_type=Dict[str, TypedDictBase64Input], + ) + + assert result == {"key": {"foo": "SGVsbG8sIHdvcmxkIQo="}} + assert async_calls >= 1, "async transform must use the async file-read path for dict values" + assert sync_calls == 0, "async transform must not fall back to the blocking sync file-read path" + + @parametrize @pytest.mark.asyncio async def test_transform_skipping(use_async: bool) -> None: