fix(client): use async transform for Dict[str, T] request params#3506
fix(client): use async transform for Dict[str, T] request params#3506LeSingh1 wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
💡 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()} |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
_async_transform_recursive(insrc/openai/_utils/_transform.py) recurses intoDict[str, T]values by calling the synchronous_transform_recursiveinstead of its async counterpart:Every other recursive call in that async function already uses the async variants —
_async_transform_typeddictand, in the list branch immediately below,await _async_transform_recursive(...). The identical dict branch in the sync_transform_recursivelegitimately calls sync, so this looks like a copy‑paste slip when the async pipeline was added.Why it's a bug
async_transformexists specifically to keep file/base64 I/O off the event loop. For anyDict[str, T]‑typed request param whose values carry file content (aPropertyInfo(format="base64")field), routing dict values through the sync pipeline means theAsyncOpenAIclient reads the file with a blockingpathlib.Path.read_bytes()(_format_data) on the event loop instead of the non‑blockingawait 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]vsList[T]:_format_datacalls_async_format_datacallsDict[str, Inner](before fix)List[Inner](already correct)Fix
Route dict values through
await _async_transform_recursive(...), mirroring the list branch just below it. The synchronous branch is unchanged.Tests
Adds two tests to
tests/test_transform.py:test_dict_of_base64_file_input— a base64Pathfield nested inside aDict[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
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 addressIndexError/ValueErrorondictwith no type args) — this is a sync‑vs‑async routing correctness fix forDict[str, T]values.