Skip to content

fix(client): use async transform for Dict[str, T] request params#3506

Open
LeSingh1 wants to merge 1 commit into
openai:mainfrom
LeSingh1:fix/async-transform-dict-values
Open

fix(client): use async transform for Dict[str, T] request params#3506
LeSingh1 wants to merge 1 commit into
openai:mainfrom
LeSingh1:fix/async-transform-dict-values

Conversation

@LeSingh1

Copy link
Copy Markdown

Summary

_async_transform_recursive (in src/openai/_utils/_transform.py) recurses into Dict[str, T] values by calling the synchronous _transform_recursive instead of its async counterpart:

# 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()}
    #                ^^^^^^^^^^^^^^^^^^ synchronous — should be `await _async_transform_recursive(...)`

Every other recursive call in that async function already uses the async variants — _async_transform_typeddict and, in the list branch immediately below, await _async_transform_recursive(...). The identical dict branch in the sync _transform_recursive legitimately calls sync, so this looks like a copy‑paste slip when the async pipeline was added.

Why it's a bug

async_transform exists specifically to keep file/base64 I/O off the event loop. For any Dict[str, T]‑typed request param whose values carry file content (a PropertyInfo(format="base64") field), routing dict values through the sync pipeline means the AsyncOpenAI client reads the file with a blocking pathlib.Path.read_bytes() (_format_data) on the event loop instead of the non‑blocking await anyio.Path(...).read_bytes() (_async_format_data). Output bytes are identical, but the async guarantee is silently violated.

Demonstrated by instrumenting the two format helpers while transforming Dict[str, T] vs List[T]:

input sync _format_data calls async _async_format_data calls
Dict[str, Inner] (before fix) ≥1 (blocking) 0
List[Inner] (already correct) 0 ≥1

Fix

Route dict values through await _async_transform_recursive(...), mirroring the list branch just below it. The synchronous branch is unchanged.

-        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()}

Tests

Adds two tests to tests/test_transform.py:

  • test_dict_of_base64_file_input — a base64 Path field nested inside a Dict[str, T] value transforms correctly under both sync and async.
  • test_async_dict_values_use_async_transform — a regression guard that spies on the sync/async format helpers and asserts the async file‑read path is used and the blocking sync path is not. This test fails on the current code (async_calls == 0) and passes with the fix.

Verification

$ pytest tests/test_transform.py -q
59 passed
$ ruff check src/openai/_utils/_transform.py tests/test_transform.py
All checks passed!
$ ruff format --check src/openai/_utils/_transform.py tests/test_transform.py
2 files already formatted

Confirmed the new regression test fails on main (reverting only the one‑line fix) and passes with it.

This is distinct from the existing bare‑dict‑annotation transform PRs (which address IndexError/ValueError on dict with no type args) — this is a sync‑vs‑async routing correctness fix for Dict[str, T] values.

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.
@LeSingh1 LeSingh1 requested a review from a team as a code owner July 14, 2026 22:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 735c25def0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve model excludes in async dict recursion

For async transforms of a Dict[str, Model] whose values are pydantic models with client-only fields excluded via __api_exclude__ (for example ParsedResponseFunctionToolCall excludes parsed_arguments), this new path now routes those values through _async_transform_recursive. Unlike the sync branch it replaced, the async BaseModel dump does not pass exclude=getattr(data, "__api_exclude__", None), so those dict-valued async request params will now serialize fields that were previously stripped. Please make the async BaseModel handling honor __api_exclude__ before switching dict values onto it.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant