Skip to content

Commit 009b7f6

Browse files
fix(parsing): drop TextFormatT parameterization in parse_response to fix memory leak (openai#3084) (openai#3088)
## Why Closes openai#3084. `AsyncResponses.parse()` leaks pydantic schema objects without bound. The issue author captured it with a flame graph — every call allocates a fresh Rust-backed `SchemaValidator`/`SchemaSerializer` that never gets freed. ## Root cause `parse_response()` called `construct_type_unchecked` with three types parameterized by a free module-level `TypeVar`: ```python construct_type_unchecked(type_=ParsedResponseOutputText[TextFormatT], ...) construct_type_unchecked(type_=ParsedResponseOutputMessage[TextFormatT], ...) construct_type_unchecked(type_=ParsedResponse[TextFormatT], ...) ``` Pydantic can't resolve a free `TypeVar`, so `model_rebuild(raise_errors=False)` returns `False`. `MockCoreSchema._built_memo` only caches the rebuilt schema when `model_rebuild` succeeds — so the cache is never populated and a new schema is allocated on every call. ## Fix Drop the `[TextFormatT]` parameterization at the `type_=` argument. At runtime Python's generics are erased anyway — the constructed object's type is identical either way. Only the pydantic schema rebuild path differs: | | Before | After | |---|---|---| | `type_` arg | `ParsedResponse[TextFormatT]` | `ParsedResponse` | | `model_rebuild` | always returns `False` | succeeds | | `_built_memo` | never populated | populated once, reused | | Per-call allocation | heavy Rust objects | cache hit | This is exactly what the issue author diagnosed. ## Verification - Ran the existing `tests/lib/responses/test_responses.py` suite locally: **5/5 passing** — no functional regression. - The change is purely about schema caching behavior. Callers see no difference in returned objects or their types. ## Scope Minimal — 3 sites in one file, inline comments pointing back to openai#3084 for maintainability. No public API change, no test changes needed (existing tests cover the parse path). --------- Signed-off-by: Mukunda Katta <mukunda.vjcs6@gmail.com> Co-authored-by: Marcus Wood <marcuswood@openai.com>
1 parent bccad31 commit 009b7f6

4 files changed

Lines changed: 49 additions & 7 deletions

File tree

‎src/openai/lib/_parsing/_responses.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def parse_response(
5656
input_tools: Iterable[ToolParam] | Omit | None,
5757
response: Response | ParsedResponse[object],
5858
) -> ParsedResponse[TextFormatT]:
59+
# Keep generic annotations in quoted casts: runtime specialization can create
60+
# new model classes in each async context and retain them in the type adapter cache.
5961
output_list: List[ParsedResponseOutputItem[TextFormatT]] = []
6062

6163
for output in response.output or []:
@@ -68,7 +70,7 @@ def parse_response(
6870

6971
content_list.append(
7072
construct_type_unchecked(
71-
type_=ParsedResponseOutputText[TextFormatT],
73+
type_=cast("type[ParsedResponseOutputText[TextFormatT]]", ParsedResponseOutputText),
7274
value={
7375
**item.to_dict(),
7476
"parsed": parse_text(item.text, text_format=text_format, phase=output.phase),
@@ -78,7 +80,7 @@ def parse_response(
7880

7981
output_list.append(
8082
construct_type_unchecked(
81-
type_=ParsedResponseOutputMessage[TextFormatT],
83+
type_=cast("type[ParsedResponseOutputMessage[TextFormatT]]", ParsedResponseOutputMessage),
8284
value={
8385
**output.to_dict(),
8486
"content": content_list,
@@ -133,7 +135,7 @@ def parse_response(
133135
output_list.append(output)
134136

135137
return construct_type_unchecked(
136-
type_=ParsedResponse[TextFormatT],
138+
type_=cast("type[ParsedResponse[TextFormatT]]", ParsedResponse),
137139
value={
138140
**response.to_dict(),
139141
"output": output_list,

‎src/openai/lib/streaming/responses/_responses.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ def handle_event(self, event: RawResponseStreamEvent) -> List[ResponseStreamEven
283283

284284
events.append(
285285
build(
286-
ResponseTextDoneEvent[TextFormatT],
286+
cast("type[ResponseTextDoneEvent[TextFormatT]]", ResponseTextDoneEvent),
287287
content_index=event.content_index,
288288
item_id=event.item_id,
289289
output_index=event.output_index,

‎src/openai/resources/responses/responses.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,7 @@ def parser(raw_response: Response) -> ParsedResponse[TextFormatT]:
14221422
),
14231423
# we turn the `Response` instance into a `ParsedResponse`
14241424
# in the `parser` function above
1425-
cast_to=cast(Type[ParsedResponse[TextFormatT]], Response),
1425+
cast_to=cast("Type[ParsedResponse[TextFormatT]]", Response),
14261426
)
14271427

14281428
@overload
@@ -3305,7 +3305,7 @@ def parser(raw_response: Response) -> ParsedResponse[TextFormatT]:
33053305
),
33063306
# we turn the `Response` instance into a `ParsedResponse`
33073307
# in the `parser` function above
3308-
cast_to=cast(Type[ParsedResponse[TextFormatT]], Response),
3308+
cast_to=cast("Type[ParsedResponse[TextFormatT]]", Response),
33093309
)
33103310

33113311
@overload

‎tests/lib/responses/test_commentary_parsing.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from __future__ import annotations
22

33
import json
4+
import asyncio
5+
import contextvars
46
from typing import Any
57

68
import httpx2
79
import pytest
810
from pydantic import BaseModel, ValidationError
911

10-
from openai import OpenAI, AsyncOpenAI, omit
12+
from openai import OpenAI, AsyncOpenAI, omit, _models
1113
from openai._types import Omit
1214
from openai.types.responses import ParsedResponse
1315
from openai.lib.streaming.responses import ResponseStreamEvent
@@ -309,3 +311,41 @@ async def test_phase_selection_preserves_content(sync: bool, streaming: bool, ph
309311
assert content.parsed == (Result(answer="final") if actual.phase == "final_answer" else None)
310312
else:
311313
assert content.refusal == raw["refusal"]
314+
315+
316+
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
317+
@pytest.mark.parametrize("streaming", [False, True], ids=["parse", "stream"])
318+
async def test_parsing_reuses_types_across_contexts(sync: bool, streaming: bool) -> None:
319+
response_types: set[tuple[type, type, type]] = set()
320+
event_types: set[type] = set()
321+
322+
async def request() -> None:
323+
if streaming:
324+
emitted, response = await _stream(sync, _events("Preparing.", '{"answer":"final"}'))
325+
done = [event for event in emitted if event.type == "response.output_text.done"]
326+
assert [event.parsed for event in done] == [None, Result(answer="final")]
327+
event_types.update(type(event) for event in done)
328+
else:
329+
response = await _parse(sync, [_message('{"answer":"final"}')])
330+
assert response.output_parsed == Result(answer="final")
331+
message = response.output[-1]
332+
assert message.type == "message"
333+
part = message.content[0]
334+
assert part.type == "output_text"
335+
assert part.parsed == Result(answer="final")
336+
response_types.add((type(response), type(message), type(part)))
337+
338+
# Inherited contexts can share Pydantic's generic cache and hide the leak.
339+
for _ in range(3):
340+
await contextvars.Context().run(asyncio.create_task, request())
341+
342+
# Pydantic v1 has no TypeAdapter cache, but still exercises type reuse below.
343+
cache_info = getattr(getattr(_models, "_CachedTypeAdapter", None), "cache_info", None)
344+
before = cache_info().currsize if cache_info is not None else None
345+
for _ in range(10):
346+
await contextvars.Context().run(asyncio.create_task, request())
347+
if cache_info is not None:
348+
assert cache_info().currsize == before
349+
assert len(response_types) == 1
350+
if streaming:
351+
assert len(event_types) == 1

0 commit comments

Comments
 (0)