Skip to content
Merged
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
21 changes: 21 additions & 0 deletions src/openai/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
)
from ._utils import (
PropertyInfo,
is_dict,
is_list,
is_given,
json_safe,
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/lib/responses/test_websocket_event_aliases.py
Original file line number Diff line number Diff line change
@@ -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]}
53 changes: 51 additions & 2 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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')"
Expand Down
Loading