Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,29 @@ def parse_response(
continue

content_list.append(
construct_type_unchecked(
type_=ParsedResponseOutputText[TextFormatT],
value={
**item.to_dict(),
"parsed": parse_text(item.text, text_format=text_format),
},
cast(
"ParsedResponseOutputText[TextFormatT]",
construct_type_unchecked(
# Unparameterised: lets Pydantic cache the schema; free TypeVar causes unbounded rebuilds.
type_=ParsedResponseOutputText,
value={
**item.to_dict(),
"parsed": parse_text(item.text, text_format=text_format),
},
),
)
)

output_list.append(
construct_type_unchecked(
type_=ParsedResponseOutputMessage[TextFormatT],
value={
**output.to_dict(),
"content": content_list,
},
cast(
"ParsedResponseOutputMessage[TextFormatT]",
construct_type_unchecked(
type_=ParsedResponseOutputMessage,
value={
**output.to_dict(),
"content": content_list,
},
),
)
)
elif output.type == "function_call":
Expand Down Expand Up @@ -129,12 +136,15 @@ def parse_response(
else:
output_list.append(output)

return construct_type_unchecked(
type_=ParsedResponse[TextFormatT],
value={
**response.to_dict(),
"output": output_list,
},
return cast(
"ParsedResponse[TextFormatT]",
construct_type_unchecked(
type_=ParsedResponse,
value={
**response.to_dict(),
"output": output_list,
},
),
)


Expand Down
94 changes: 94 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
from __future__ import annotations

import gc

from typing_extensions import TypeVar

import pytest
import pydantic
from respx import MockRouter
from inline_snapshot import snapshot

from openai import OpenAI, AsyncOpenAI
from openai._utils import assert_signatures_in_sync
from openai._models import construct_type_unchecked
from openai.types.responses import Response
from openai.lib._parsing._responses import parse_response

from ...conftest import base_url
from ..snapshots import make_snapshot_request
Expand Down Expand Up @@ -61,3 +67,91 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien
checking_client.responses.parse,
exclude_params={"tools"},
)


_MINIMAL_RESPONSE_DICT = {
"id": "resp_test",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"model": "gpt-4o-mini",
"output": [
{
"id": "msg_test",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": '{"name": "Birthday Party", "date": "2026-06-01"}',
"annotations": [],
"logprobs": [],
}
],
}
],
"parallel_tool_calls": True,
"reasoning": {"effort": None, "summary": None},
"text": {"format": {"type": "text"}, "verbosity": "medium"},
"tool_choice": "auto",
"tools": [],
"truncation": "disabled",
"usage": {
"input_tokens": 10,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens": 10,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": 20,
},
}


class _CalendarEvent(pydantic.BaseModel):
name: str
date: str


def test_parse_response_structured_output_correctness() -> None:
"""parse_response returns correctly-typed and correctly-valued output."""
response = construct_type_unchecked(type_=Response, value=_MINIMAL_RESPONSE_DICT)

parsed = parse_response(text_format=_CalendarEvent, input_tools=None, response=response)

assert len(parsed.output) == 1
msg = parsed.output[0]
assert msg.type == "message"
content = msg.content[0]
assert content.type == "output_text"
assert isinstance(content.parsed, _CalendarEvent)
assert content.parsed.name == "Birthday Party"
assert content.parsed.date == "2026-06-01"


def test_parse_response_no_pydantic_schema_leak() -> None:
"""parse_response must not allocate new SchemaValidator objects on every call.

Using ParsedResponse[free_TypeVar] prevents Pydantic from caching the schema,
causing a new SchemaValidator/SchemaSerializer per call (issue #3084).
"""
response = construct_type_unchecked(type_=Response, value=_MINIMAL_RESPONSE_DICT)

# One warm-up call triggers the initial (and only legitimate) schema build.
parse_response(text_format=_CalendarEvent, input_tools=None, response=response)

def _count_validators() -> int:
return sum(1 for obj in gc.get_objects() if type(obj).__name__ == "SchemaValidator")

gc.collect()
before = _count_validators()

for _ in range(50):
parse_response(text_format=_CalendarEvent, input_tools=None, response=response)

gc.collect()
after = _count_validators()

assert after == before, (
f"parse_response leaked {after - before} SchemaValidator object(s) over 50 calls. "
"The Pydantic schema for ParsedResponse and friends must be built once and cached."
)