From 91a21caff6bc627577b3a4938caa02e93726a31f Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 5 Jun 2026 13:23:01 -0700 Subject: [PATCH 1/3] Fall back to model_dump_json for OpenAI payload serialization OpenAI response and stream event types whose pydantic serializer is a lazily-built MockValSer cannot be serialized by the generic any-schema serializer, raising PydanticSerializationError (e.g. when streaming via WorkflowStreamClient). The model's own model_dump_json() handles them. Fixes #1585 --- .../openai_agents/_temporal_openai_agents.py | 16 ++++++++ tests/contrib/openai_agents/test_openai.py | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 43594657f..a743b8fda 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -8,6 +8,7 @@ from datetime import timedelta import pydantic +import pydantic_core from agents import ModelProvider, Trace, set_trace_provider from agents.run import get_default_agent_runner, set_default_agent_runner from agents.tracing import get_trace_provider @@ -110,6 +111,21 @@ class _OpenAIJSONPlainPayloadConverter(PydanticJSONPlainPayloadConverter): side does not, so fall back to lenient construction when validation fails. """ + def to_payload(self, value: typing.Any) -> temporalio.api.common.v1.Payload | None: + try: + return super().to_payload(value) + except pydantic_core.PydanticSerializationError: + dump = getattr(value, "model_dump_json", None) + if dump is None: + raise + exclude_unset = ( + self._to_json_options.exclude_unset if self._to_json_options else False + ) + return temporalio.api.common.v1.Payload( + metadata={"encoding": self.encoding.encode()}, + data=dump(exclude_unset=exclude_unset).encode(), + ) + def from_payload( self, payload: temporalio.api.common.v1.Payload, diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index de0af3923..9238bfa54 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -1415,6 +1415,43 @@ async def test_response_serialization(): await pydantic_data_converter.encode([model_response]) +def test_openai_converter_serialization_fallback(monkeypatch: pytest.MonkeyPatch): + import pydantic_core + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + from temporalio.contrib.openai_agents._temporal_openai_agents import ( + _OpenAIJSONPlainPayloadConverter, + ) + from temporalio.contrib.pydantic import ToJsonOptions + + converter = _OpenAIJSONPlainPayloadConverter(ToJsonOptions(exclude_unset=True)) + + class _RaisingSerializer: + def to_json(self, *_args: Any, **_kwargs: Any) -> bytes: + raise pydantic_core.PydanticSerializationError("forced") + + monkeypatch.setattr(converter, "_schema_serializer", _RaisingSerializer()) + + value = ResponseOutputMessage( + id="", + content=[ResponseOutputText(text="hello", annotations=[], type="output_text")], + role="assistant", + status="completed", + type="message", + ) + payload = converter.to_payload(value) + assert payload is not None + assert payload.metadata["encoding"] == b"json/plain" + assert payload.data == value.model_dump_json(exclude_unset=True).encode() + + decoded = converter.from_payload(payload, type(value)) + assert decoded == value + + # A non-model value has no model_dump_json, so the error propagates. + with pytest.raises(pydantic_core.PydanticSerializationError): + converter.to_payload(object()) + + async def assert_status_retry_behavior(status: int, client: Client, should_retry: bool): def status_error(status: int): with workflow.unsafe.imports_passed_through(): From d87adad02df4777fcaa399f530dccc9ed04206ba Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 5 Jun 2026 14:36:21 -0700 Subject: [PATCH 2/3] Dispatch pydantic models to their own serializer OpenAI's BaseModel sets defer_build=True, so a model's serializer is a MockValSer placeholder until pydantic's lazy build runs. The generic any-schema serializer reaches for that placeholder directly without triggering the build and raises PydanticSerializationError. Route pydantic models through their own model_dump_json (which triggers the build) by type instead of catching the error; non-model values continue through the generic serializer unchanged. --- .../openai_agents/_temporal_openai_agents.py | 12 ++++-------- tests/contrib/openai_agents/test_openai.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index a743b8fda..fa69cf636 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -8,7 +8,6 @@ from datetime import timedelta import pydantic -import pydantic_core from agents import ModelProvider, Trace, set_trace_provider from agents.run import get_default_agent_runner, set_default_agent_runner from agents.tracing import get_trace_provider @@ -112,19 +111,16 @@ class _OpenAIJSONPlainPayloadConverter(PydanticJSONPlainPayloadConverter): """ def to_payload(self, value: typing.Any) -> temporalio.api.common.v1.Payload | None: - try: - return super().to_payload(value) - except pydantic_core.PydanticSerializationError: - dump = getattr(value, "model_dump_json", None) - if dump is None: - raise + """See base class.""" + if isinstance(value, pydantic.BaseModel): exclude_unset = ( self._to_json_options.exclude_unset if self._to_json_options else False ) return temporalio.api.common.v1.Payload( metadata={"encoding": self.encoding.encode()}, - data=dump(exclude_unset=exclude_unset).encode(), + data=value.model_dump_json(exclude_unset=exclude_unset).encode(), ) + return super().to_payload(value) def from_payload( self, diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 9238bfa54..8a644f43d 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -1415,7 +1415,11 @@ async def test_response_serialization(): await pydantic_data_converter.encode([model_response]) -def test_openai_converter_serialization_fallback(monkeypatch: pytest.MonkeyPatch): +def test_openai_converter_uses_model_serializer(monkeypatch: pytest.MonkeyPatch): + # OpenAI models set defer_build=True, so the generic any-schema serializer + # trips on their MockValSer placeholder. The converter must route pydantic + # models through their own serializer instead. Prove the routing by making + # the generic serializer raise: models still serialize, non-models don't. import pydantic_core from openai.types.responses import ResponseOutputMessage, ResponseOutputText @@ -1428,7 +1432,7 @@ def test_openai_converter_serialization_fallback(monkeypatch: pytest.MonkeyPatch class _RaisingSerializer: def to_json(self, *_args: Any, **_kwargs: Any) -> bytes: - raise pydantic_core.PydanticSerializationError("forced") + raise pydantic_core.PydanticSerializationError("generic serializer used") monkeypatch.setattr(converter, "_schema_serializer", _RaisingSerializer()) @@ -1439,6 +1443,7 @@ def to_json(self, *_args: Any, **_kwargs: Any) -> bytes: status="completed", type="message", ) + # A pydantic model bypasses the generic serializer and uses its own. payload = converter.to_payload(value) assert payload is not None assert payload.metadata["encoding"] == b"json/plain" @@ -1447,9 +1452,9 @@ def to_json(self, *_args: Any, **_kwargs: Any) -> bytes: decoded = converter.from_payload(payload, type(value)) assert decoded == value - # A non-model value has no model_dump_json, so the error propagates. + # Non-model values still route through the generic serializer. with pytest.raises(pydantic_core.PydanticSerializationError): - converter.to_payload(object()) + converter.to_payload({"not": "a model"}) async def assert_status_retry_behavior(status: int, client: Client, should_retry: bool): From 0286405f1cd76430fb18f241672d18a03c8265fe Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 5 Jun 2026 14:59:18 -0700 Subject: [PATCH 3/3] Build streamed events at the source instead of in the converter Force the deferred pydantic build on each streamed event before it is published or returned, so it serializes regardless of build state. This also covers the activity's list return value, which the payload converter serializes generically and cannot build on its own. Drop the now-redundant to_payload override. --- .../openai_agents/_invoke_model_activity.py | 3 ++ .../openai_agents/_temporal_openai_agents.py | 12 ------ tests/contrib/openai_agents/test_openai.py | 42 ------------------- 3 files changed, 3 insertions(+), 54 deletions(-) diff --git a/temporalio/contrib/openai_agents/_invoke_model_activity.py b/temporalio/contrib/openai_agents/_invoke_model_activity.py index a43f9aeaf..3f7a639dd 100644 --- a/temporalio/contrib/openai_agents/_invoke_model_activity.py +++ b/temporalio/contrib/openai_agents/_invoke_model_activity.py @@ -393,6 +393,9 @@ async def invoke_model_activity_streaming( conversation_id=input.get("conversation_id"), prompt=input.get("prompt"), ): + # OpenAI models set defer_build=True, so an event's pydantic + # schema may still be an unbuilt placeholder. + type(event).model_rebuild() events.append(event) events_topic.publish(event) except APIStatusError as e: diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index fa69cf636..43594657f 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -110,18 +110,6 @@ class _OpenAIJSONPlainPayloadConverter(PydanticJSONPlainPayloadConverter): side does not, so fall back to lenient construction when validation fails. """ - def to_payload(self, value: typing.Any) -> temporalio.api.common.v1.Payload | None: - """See base class.""" - if isinstance(value, pydantic.BaseModel): - exclude_unset = ( - self._to_json_options.exclude_unset if self._to_json_options else False - ) - return temporalio.api.common.v1.Payload( - metadata={"encoding": self.encoding.encode()}, - data=value.model_dump_json(exclude_unset=exclude_unset).encode(), - ) - return super().to_payload(value) - def from_payload( self, payload: temporalio.api.common.v1.Payload, diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 8a644f43d..de0af3923 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -1415,48 +1415,6 @@ async def test_response_serialization(): await pydantic_data_converter.encode([model_response]) -def test_openai_converter_uses_model_serializer(monkeypatch: pytest.MonkeyPatch): - # OpenAI models set defer_build=True, so the generic any-schema serializer - # trips on their MockValSer placeholder. The converter must route pydantic - # models through their own serializer instead. Prove the routing by making - # the generic serializer raise: models still serialize, non-models don't. - import pydantic_core - from openai.types.responses import ResponseOutputMessage, ResponseOutputText - - from temporalio.contrib.openai_agents._temporal_openai_agents import ( - _OpenAIJSONPlainPayloadConverter, - ) - from temporalio.contrib.pydantic import ToJsonOptions - - converter = _OpenAIJSONPlainPayloadConverter(ToJsonOptions(exclude_unset=True)) - - class _RaisingSerializer: - def to_json(self, *_args: Any, **_kwargs: Any) -> bytes: - raise pydantic_core.PydanticSerializationError("generic serializer used") - - monkeypatch.setattr(converter, "_schema_serializer", _RaisingSerializer()) - - value = ResponseOutputMessage( - id="", - content=[ResponseOutputText(text="hello", annotations=[], type="output_text")], - role="assistant", - status="completed", - type="message", - ) - # A pydantic model bypasses the generic serializer and uses its own. - payload = converter.to_payload(value) - assert payload is not None - assert payload.metadata["encoding"] == b"json/plain" - assert payload.data == value.model_dump_json(exclude_unset=True).encode() - - decoded = converter.from_payload(payload, type(value)) - assert decoded == value - - # Non-model values still route through the generic serializer. - with pytest.raises(pydantic_core.PydanticSerializationError): - converter.to_payload({"not": "a model"}) - - async def assert_status_retry_behavior(status: int, client: Client, should_retry: bool): def status_error(status: int): with workflow.unsafe.imports_passed_through():