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
47 changes: 28 additions & 19 deletions src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING, List, Iterable, cast
from typing import TYPE_CHECKING, Any, List, Iterable, cast
from typing_extensions import TypeVar, assert_never

import pydantic
Expand Down Expand Up @@ -67,22 +67,28 @@ 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(
Any,
Comment thread
erhan1209 marked this conversation as resolved.
construct_type_unchecked(
type_=ParsedResponseOutputText,
value={
**item.to_dict(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Using cast(Any, ...) to bypass construct_type_unchecked's type parameterization check discards Pydantic's validation at the call site. If value contains an unexpected field or wrong type, Pydantic will now silently accept it instead of raising a ValidationError. Consider adding an explicit model validation step after construction to preserve runtime safety:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I may be missing your concern, but I don’t think cast(...) changes the runtime behavior here.

cast(...) is only for static typing; the runtime construction path is still construct_type_unchecked(...), which this code was already using before this patch. The runtime change in this PR is only switching the type_= target from parameterized generics like ParsedResponseOutputText[TextFormatT] to the corresponding non-parameterized runtime classes, to avoid repeated schema rebuilds.

If your concern is with the use of construct_type_unchecked(...) more generally, or with the cast-based typing shape, I’m happy to adjust the implementation to express the same runtime behavior more clearly.

"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(
Any,
construct_type_unchecked(
type_=ParsedResponseOutputMessage,
value={
**output.to_dict(),
"content": content_list,
},
),
)
)
elif output.type == "function_call":
Expand Down Expand Up @@ -129,12 +135,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
84 changes: 84 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
from __future__ import annotations

from typing import Any
from typing_extensions import TypeVar

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

from openai import OpenAI, AsyncOpenAI
from openai._utils import assert_signatures_in_sync
from openai.types.responses import Response
from openai.lib._parsing._responses import parse_response
from openai.types.responses.parsed_response import (
ParsedResponse,
ParsedResponseOutputText,
ParsedResponseOutputMessage,
)

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


def test_parse_response_uses_non_parameterized_runtime_types(monkeypatch: pytest.MonkeyPatch) -> None:
class Payload(BaseModel):
message: str

response = Response.model_validate(
{
"id": "resp_123",
"object": "response",
"created_at": 0,
"status": "completed",
"background": False,
"error": None,
"incomplete_details": None,
"instructions": None,
"max_output_tokens": None,
"max_tool_calls": None,
"model": "gpt-4o-mini",
"output": [
{
"id": "msg_123",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"annotations": [],
"logprobs": [],
"text": '{"message":"hello"}',
}
],
}
],
"parallel_tool_calls": True,
"previous_response_id": None,
"prompt_cache_key": None,
"reasoning": {"effort": None, "summary": None},
"safety_identifier": None,
"service_tier": "default",
"store": True,
"temperature": 1.0,
"text": {"format": {"type": "text"}, "verbosity": "medium"},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 1,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens": 1,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": 2,
},
"user": None,
"metadata": {},
}
)

seen_types: list[object] = []

def capture_construct_type_unchecked(*, value: object, type_: object) -> Any:
seen_types.append(type_)
from openai._models import construct_type_unchecked

return construct_type_unchecked(value=value, type_=type_) # type: ignore[arg-type]

monkeypatch.setattr("openai.lib._parsing._responses.construct_type_unchecked", capture_construct_type_unchecked)

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

assert parsed.output_parsed == Payload(message="hello")
assert seen_types == [ParsedResponseOutputText, ParsedResponseOutputMessage, ParsedResponse]