diff --git a/src/openai/lib/beta/agents/_result.py b/src/openai/lib/beta/agents/_result.py index 9bcb2ba43d..d1f4c5d99d 100644 --- a/src/openai/lib/beta/agents/_result.py +++ b/src/openai/lib/beta/agents/_result.py @@ -1,11 +1,15 @@ from __future__ import annotations +import warnings from copy import deepcopy from typing import Any, Generic, Iterable from dataclasses import dataclass from typing_extensions import Literal, TypeVar +from pydantic import ValidationError + from ._output import validate_output_type +from ...._compat import PYDANTIC_V1, parse_obj from ...._exceptions import OpenAIError from ..._parsing._completions import _parse_content from ....types.beta.agent_session import RequiredAction @@ -135,7 +139,31 @@ def accept(self, event: AgentSessionEvent) -> None: and item.phase != "commentary" and item.status == "completed" ): - message = AgentSessionMessage.construct(_fields_set=None, **deepcopy(item.to_dict())) + # V1 emits no serialization warnings, but its shim rejects warnings=False. + data = item.to_dict(warnings=PYDANTIC_V1) + issues = [] + try: + parse_obj(type(item), data) + except ValidationError as exc: + issues = [(error["loc"], error["type"]) for error in exc.errors()] + # Warn outside the handler so warnings-as-errors do not chain the validation error. + for location, code in issues: + path = "".join(f"[{part}]" if isinstance(part, int) else f".{part}" for part in location).lstrip( + "." + ) + expected = { + "string_type": "a string", + "type_error.str": "a string", + "list_type": "a list", + "type_error.list": "a list", + }.get(code) + warnings.warn( + f"Unexpected response type at {path}: expected {expected}." + if expected + else f"Unexpected response value at {path}: {code}.", + stacklevel=2, + ) + message = AgentSessionMessage.construct(_fields_set=None, **deepcopy(data)) self._messages[item.id] = (event.output_index, message) elif event.type == "agent.session.in_progress": self.required_actions = [] diff --git a/tests/lib/streaming/agents/test_results.py b/tests/lib/streaming/agents/test_results.py index 958fc3b030..abca51d3b3 100644 --- a/tests/lib/streaming/agents/test_results.py +++ b/tests/lib/streaming/agents/test_results.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import Any, Iterator, cast from typing_extensions import override @@ -147,6 +148,42 @@ async def test_empty_success(sdk: OpenAI | AsyncOpenAI, creation: bool) -> None: assert result.messages == [] +async def test_result_schema_warning(sdk: OpenAI | AsyncOpenAI, server: ResultServer, creation: bool) -> None: + sdk._strict_response_validation = False + event = message() + event["item"]["content"][0]["text"] = ["synthetic-answer"] + + def reset() -> None: + server.body = EventBody([turn_event("created"), event, turn_event("completed"), idle()]) + + reset() + with pytest.warns( + UserWarning, match=r"^Unexpected response type at content\[0\]\.text: expected a string\.$" + ) as captured: + result = await collect(sdk, creation) + assert len(captured) == 1 + warning = captured[0].message + assert "synthetic-answer" not in str(warning) + content = result.messages[0].content[0] + assert content.type == "output_text" + assert content.text == ["synthetic-answer"] + + reset() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + with pytest.raises(AgentTurnResultError) as exc: + await collect(sdk, creation) + assert isinstance(exc.value.__cause__, UserWarning) + assert exc.value.__cause__.__context__ is None + + event["item"]["content"][0]["text"] = "synthetic-answer" + reset() + with warnings.catch_warnings(record=True) as captured_valid: + warnings.simplefilter("always") + assert (await collect(sdk, creation)).output_text == "synthetic-answer" + assert not captured_valid + + @pytest.mark.parametrize("status", ["failed", "cancelled"]) async def test_unsuccessful_turn(sdk: OpenAI | AsyncOpenAI, server: ResultServer, creation: bool, status: str) -> None: server.body = EventBody([turn_event("created"), message(), turn_event(status), idle()])