From 912bc698beae3126892cff0009d62804187fd237 Mon Sep 17 00:00:00 2001 From: markstuart-oai Date: Mon, 19 Oct 2026 22:50:55 +0000 Subject: [PATCH] fix: preserve declared aliases during Pydantic v1 validation --- src/openai/_models.py | 21 ++++++++ .../responses/test_websocket_event_aliases.py | 41 ++++++++++++++ tests/test_models.py | 53 ++++++++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/lib/responses/test_websocket_event_aliases.py diff --git a/src/openai/_models.py b/src/openai/_models.py index f0d2f97810..a9236d7183 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -53,6 +53,7 @@ ) from ._utils import ( PropertyInfo, + is_dict, is_list, is_given, json_safe, @@ -120,6 +121,26 @@ def model_fields_set(self) -> set[str]: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = pydantic.Extra.allow # type: ignore + @pydantic.root_validator(pre=True) # pyright: ignore[reportDeprecated] + @classmethod + def _preserve_wire_aliases(cls, values: Any) -> Any: + if not is_dict(values): + return values + + fields = get_model_fields(cls) + collisions = { + name + for name, field in fields.items() + if field.alias != name and name in values and field.alias in values + } + if not collisions: + return values + + # A field's Python name can be a different field's wire alias. + # In that case Pydantic consumes it as a declared field, not an extra. + collisions.difference_update(field.alias for field in fields.values()) + return {key: value for key, value in values.items() if key not in collisions} + @override def __repr_args__(self) -> ReprArgs: # we don't want these attributes to be included when something like `rich.print` is used diff --git a/tests/lib/responses/test_websocket_event_aliases.py b/tests/lib/responses/test_websocket_event_aliases.py new file mode 100644 index 0000000000..0b747e91f9 --- /dev/null +++ b/tests/lib/responses/test_websocket_event_aliases.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest + +from openai.types.responses import ResponseCustomToolCall, ResponseFunctionToolCall, ResponseOutputItemDoneEvent +from openai.resources.responses.responses import ResponsesConnection, AsyncResponsesConnection + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("status", ["completed", "future_status"]) +@pytest.mark.parametrize("collision", [False, True]) +@pytest.mark.parametrize("kind", ["function_call", "custom_tool_call"]) +def test_validated_and_future_output_items_keep_wire_alias(mode: str, status: str, collision: bool, kind: str) -> None: + item: dict[str, Any] = { + "type": kind, + "id": "call_1", + "name": "lookup", + "call_id": "tool_1", + "status": status, + "async": True, + "unknown_future": {"nested": [1, 2]}, + } + item["arguments" if kind == "function_call" else "input"] = "{}" + if collision: + item["async_"] = "unrelated future spelling" + payload = json.dumps({"type": "response.output_item.done", "sequence_number": 1, "output_index": 0, "item": item}) + if mode == "sync": + connection = object.__new__(ResponsesConnection) + else: + connection = object.__new__(AsyncResponsesConnection) + event = connection.parse_event(payload) + assert isinstance(event, ResponseOutputItemDoneEvent) + assert isinstance(event.item, (ResponseCustomToolCall, ResponseFunctionToolCall)) + assert event.item.async_ is True + assert cast(Any, event.item).unknown_future == {"nested": [1, 2]} + emitted = event.item.to_dict() + assert emitted["async"] is True + assert emitted["unknown_future"] == {"nested": [1, 2]} diff --git a/tests/test_models.py b/tests/test_models.py index a98fb37d33..4a7892d9dc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,6 @@ import json -from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Dict, List, Union, Generic, TypeVar, Iterable, Optional, cast from datetime import datetime, timezone from collections import deque from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType @@ -10,7 +11,7 @@ from openai._utils import PropertyInfo from openai._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from openai._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, construct_type +from openai._models import DISCRIMINATOR_CACHE, BaseModel, GenericModel, EagerIterable, construct_type class BasicModel(BaseModel): @@ -181,6 +182,54 @@ class Model(BaseModel): assert cast(Any, m.my_field) == {"hello": False} +def test_validated_alias_keeps_wire_field_and_leaves_input_unchanged() -> None: + class Model(BaseModel): + enabled_: bool = Field(alias="enabled") + + values = {"enabled": True, "enabled_": "future spelling", "future": {"nested": [1, 2]}} + original = deepcopy(values) + model = parse_obj(Model, values) + assert model.enabled_ is True + assert cast(Any, model).future == {"nested": [1, 2]} + assert values == original + emitted = model.to_dict() + assert emitted["enabled"] is True + assert emitted["future"] == {"nested": [1, 2]} + + +def test_validated_alias_keeps_keys_used_as_other_fields_wire_names() -> None: + class Model(BaseModel): + name: bool = Field(alias="wire_name") + other: str = Field(alias="name") + + values = {"wire_name": True, "name": "second field", "future": [3]} + original = deepcopy(values) + model = parse_obj(Model, values) + assert model.name is True + assert model.other == "second field" + assert values == original + assert model.to_dict() == {"wire_name": True, "name": "second field", "future": [3]} + + +def test_validated_alias_in_generic_container() -> None: + item_type = TypeVar("item_type") + + class Model(BaseModel): + schema_: Dict[str, object] = Field(alias="schema") + + class Envelope(GenericModel, Generic[item_type]): + data: item_type + + values = {"data": {"schema": {"type": "object"}, "schema_": "future", "later": {"nested": [4]}}} + original = deepcopy(values) + envelope = parse_obj(Envelope[Model], values) + assert envelope.data.schema_ == {"type": "object"} + assert values == original + emitted = cast(Dict[str, Any], envelope.to_dict()["data"]) + assert emitted["schema"] == {"type": "object"} + assert emitted["later"] == {"nested": [4]} + + def test_repr() -> None: model = BasicModel(foo="bar") assert str(model) == "BasicModel(foo='bar')"