Support Pydantic structured outputs in OpenAIResponseOperator#69812
Support Pydantic structured outputs in OpenAIResponseOperator#69812YAshhh29 wants to merge 5 commits into
Conversation
| f"(status={parsed.status!r}). Inspect the response via OpenAIHook.get_response " | ||
| f"for refusal / error details." | ||
| ) | ||
| return parsed.output_parsed.model_dump() |
There was a problem hiding this comment.
model_dump() defaults to python mode, so non-JSON field types come back as live Python objects. A model with a plain enum.Enum field (a common shape for classification-style structured outputs) then fails at XCom push with TypeError: cannot serialize object of type <enum 'Priority'>, because Airflow's serde only unwraps enums that mix in str/int -- and by then the API call has already been paid for. model_dump(mode="json") fixes this and is what Airflow's own pydantic serializer does (serde/serializers/pydantic.py), making the docstring's XCom-safe claim hold for any model.
| self.log.info("Generated response %s", parsed.id) | ||
| if parsed.output_parsed is None: | ||
| # No structured output — the model refused, the request errored, or the response | ||
| # was truncated. Surface a clear error so downstream tasks don't get None. |
There was a problem hiding this comment.
The "response was truncated" case isn't actually covered by the None check: when output is cut off mid-JSON (e.g. max_output_tokens hit), responses.parse() raises pydantic.ValidationError from its internal parse step (checked on openai 2.37.0: parse_text calls model_parse_json with no try/except). That happens inside self.hook.parse_response(...) before parsed is assigned, so the response id is never logged and this message never fires; users get a raw pydantic traceback instead. Wrapping the parse_response call in try/except ValidationError and re-raising the same shaped ValueError would make this comment and the guide's claim hold.
And for the cases that do reach this raise, the response already carries the detail (parsed.error, parsed.incomplete_details, refusal text in parsed.output) -- including it in the message would save users the get_response round trip.
| By default the operator is synchronous and returns the response's aggregated output text. | ||
| Pass ``text_format`` (a Pydantic ``BaseModel`` subclass) to request a structured output; the | ||
| operator then returns the parsed model as a ``dict`` (via ``model_dump()``), which is safe to | ||
| push to XCom. Requires a model that supports structured outputs (``gpt-4o-2024-08-06`` and |
There was a problem hiding this comment.
This reads as if the default model doesn't qualify, but gpt-4o-mini (the default here and in parse_response) has supported structured outputs since its initial release -- per OpenAI's structured outputs guide it's the gpt-4o-mini, gpt-4o-mini-2024-07-18, and gpt-4o-2024-08-06 snapshots and later. The same claim repeats in the hook docstring, the rst guide, and the example DAG (which overrides model="gpt-4o-2024-08-06" with a comment saying it's required, when the default already works). I'd drop the specific-model claim (it will rot as new models ship) or note that the default already supports it.
One more thing in this docstring: this line says to use the hook directly for previous_response_id chaining, but the response_kwargs description below lists previous_response_id as a supported passthrough. One of the two should give.
| text_format: type[BaseModel], | ||
| model: str = "gpt-4o-mini", | ||
| **kwargs: Any, | ||
| ) -> ParsedResponse[Any]: |
There was a problem hiding this comment.
The SDK's parse is generic (ParsedResponse[TextFormatT]), so you can keep that instead of hardcoding ParsedResponse[Any]: T = TypeVar("T", bound=BaseModel), text_format: type[T], return ParsedResponse[T] -- then output_parsed is typed as T | None at call sites. Also, can you add parse_response to the hook-methods list in docs/operators/openai.rst alongside the other Responses methods?
|
|
||
|
|
||
| def test_parse_response(mock_openai_hook): | ||
| from pydantic import BaseModel |
There was a problem hiding this comment.
Can you move this import to the module top? pydantic is a hard dependency (the openai SDK requires it), and the operator test file in this PR already imports it top-level.
…fusal Five fixes from the review of apache#69812: (1) OpenAIResponseOperator now calls model_dump(mode='json'), so non-JSON field types (plain enum.Enum, datetime, UUID, ...) come back as their JSON representations instead of live Python objects. The old default (mode='python') would break XCom push for models with a plain enum field, since Airflow's serde only unwraps enums that mix in str/int -- and the API call has already been paid for by then. mode='json' is what airflow.serialization.serde.serializers.pydantic already uses, so this alignment holds the operator's XCom-safe claim for any model. (2) parse_response is now wrapped in try/except ValidationError, re-raising as ValueError. responses.parse() raises pydantic.ValidationError internally when the model's JSON output can't be coerced into text_format -- most commonly when the response is truncated mid-JSON on max_output_tokens. Without the wrap, users got a raw pydantic traceback; now they get a single ValueError shape across all parse failures. (3) The output_parsed=None ValueError now includes the API-reported error and incomplete_details in the message, so users don't need a follow-up OpenAIHook.get_response round-trip to diagnose a refusal. (4) Dropped the docstring claim that structured outputs require 'gpt-4o-2024-08-06 or later' -- the default gpt-4o-mini already supports them (has since its initial release), and specific model claims rot as new models ship. Also removed the contradictory 'use hook directly for previous_response_id chaining' line, since response_kwargs already forwards previous_response_id to the SDK. (5) OpenAIHook.parse_response is now generic (TypeVar bound to BaseModel), returning ParsedResponse[T] instead of ParsedResponse[Any]. Callers get output_parsed typed as T | None, matching the SDK's own signature. Also fixes the failing docs --spellcheck-only CI job by rephrasing 'parseable' -> 'return a structured output' throughout, moves the pydantic import in test_openai.py to module top, adds parse_response to the hook-methods list in openai.rst, drops the redundant model= override from the example DAG, and adds a regression test for both the enum-field dump-mode and the ValidationError-to-ValueError conversion.
|
Thanks @kaxil — this is a genuinely great review. Every one of the five catches
All five landed in
Bonus: the failing Author reviewI read every fix line-by-line before pushing and validated the two behavioral Drafted-by: GitHub Copilot (Claude Opus 4.6); reviewed by @YAshhh29 before posting |
The OpenAIResponseOperator currently returns only the aggregated output text of a Responses API call. Users who want a structured JSON output -- the pattern every AI-eng team uses for reliable extraction, tool selection, and downstream DAG chaining -- have to bypass the operator and call the hook directly, as the operator's own docstring instructs. Pass a Pydantic BaseModel subclass as text_format and the operator now uses the SDK's structured-output path (responses.parse), returning output_parsed.model_dump() -- a plain dict, XCom-safe, no manual serialization dance. When text_format is None the operator's existing plain-text behavior is preserved bit-for-bit. If the model refuses or produces a non-parseable response, the operator raises a clear ValueError so downstream tasks do not silently receive None.
Covers the hook.parse_response wrapper (arg forwarding), the operator happy path (parsed model returned as dict, create_response not called), and the refusal path (output_parsed=None raises ValueError). The existing plain-text test is left untouched to guard the backward-compat path.
Adds a Structured outputs (Pydantic models) subsection under the existing OpenAIResponseOperator how-to, with an exampleinclude pointing at the new [START/END] howto_operator_openai_response_structured markers in the example DAG. Calls out the compatible-model requirement (gpt-4o-2024-08-06 or later) and the ValueError raised on refusal.
…fusal Five fixes from the review of apache#69812: (1) OpenAIResponseOperator now calls model_dump(mode='json'), so non-JSON field types (plain enum.Enum, datetime, UUID, ...) come back as their JSON representations instead of live Python objects. The old default (mode='python') would break XCom push for models with a plain enum field, since Airflow's serde only unwraps enums that mix in str/int -- and the API call has already been paid for by then. mode='json' is what airflow.serialization.serde.serializers.pydantic already uses, so this alignment holds the operator's XCom-safe claim for any model. (2) parse_response is now wrapped in try/except ValidationError, re-raising as ValueError. responses.parse() raises pydantic.ValidationError internally when the model's JSON output can't be coerced into text_format -- most commonly when the response is truncated mid-JSON on max_output_tokens. Without the wrap, users got a raw pydantic traceback; now they get a single ValueError shape across all parse failures. (3) The output_parsed=None ValueError now includes the API-reported error and incomplete_details in the message, so users don't need a follow-up OpenAIHook.get_response round-trip to diagnose a refusal. (4) Dropped the docstring claim that structured outputs require 'gpt-4o-2024-08-06 or later' -- the default gpt-4o-mini already supports them (has since its initial release), and specific model claims rot as new models ship. Also removed the contradictory 'use hook directly for previous_response_id chaining' line, since response_kwargs already forwards previous_response_id to the SDK. (5) OpenAIHook.parse_response is now generic (TypeVar bound to BaseModel), returning ParsedResponse[T] instead of ParsedResponse[Any]. Callers get output_parsed typed as T | None, matching the SDK's own signature. Also fixes the failing docs --spellcheck-only CI job by rephrasing 'parseable' -> 'return a structured output' throughout, moves the pydantic import in test_openai.py to module top, adds parse_response to the hook-methods list in openai.rst, drops the redundant model= override from the example DAG, and adds a regression test for both the enum-field dump-mode and the ValidationError-to-ValueError conversion.
beb3d9e to
59297e5
Compare
|
Could you also run a sample Dag with the this, and show screenshot of Airflow UI to show status and logs please |
|
Will do. This branch has been developed and unit-tested on a Windows workstation, |
|
Done — ran the Environment: Airflow standalone on this branch ( Sample DAG ( class Person(BaseModel):
name: str
age: int
OpenAIResponseOperator(
task_id="extract_person",
conn_id="openai_default",
model="gpt-4o-mini",
input_text="Extract the name and age from: 'Alice is 30 years old.'",
text_format=Person,
)Result: the task succeeds and pushes the parsed structured output to XCom as a plain dict — {"name": "Alice", "age": 30} — no manual serialization, exactly the flow this PR adds. gpt-4o-mini (the default) handled structured outputs fine, matching your note about dropping the specific-model claim. Screenshots attached below:
|
|
Fixed — dragged the images in this time instead of pasting raw |
|
@YAshhh29 The screenshot doesn't look from the |











The
OpenAIResponseOperatortoday only returns the aggregatedoutput_textof a Responses API call. Users who want a structured JSON output — the pattern
every AI-eng team uses for reliable extraction, tool selection, and downstream
DAG chaining — have to bypass the operator entirely. The operator's own
docstring instructs this:
This PR closes that gap. Pass a Pydantic
BaseModelsubclass astext_formatandthe operator uses the SDK's structured-output path (
responses.parse) and returnsoutput_parsed.model_dump()— a plaindict, XCom-safe, no manual serializationdance. When
text_formatisNonethe existing plain-text behavior is preservedbit-for-bit.
How I found and verified this gap
Same audit approach as #69408, #69534, and #69673: read every operator/hook in the
AI/ML providers and compare surfaced API to underlying SDK. The Responses API is
OpenAI's recommended interface going forward (Chat Completions is on the deprecation
path), so gaps here are user-facing today, not legacy cleanup.
Before writing a line of code I confirmed:
openai.resources.responses.Responses.parse(input, model, text_format, ...)is present in the pinned SDK (openai>=2.37.0) and returnsParsedResponse[TextFormatT]with.output_parsedas an instance of the passed model. Verified viainspect.signature(Responses.parse).output_parsedis None on refusal / incomplete. The SDK docs and source confirm the model returning a refusal or the response being truncated yieldsoutput_parsed=None. The operator raisesValueErrorin that case so downstream tasks don't silently getNone.model_dump()produces XCom-safe dicts. Every pinned Pydantic version supports it.pydanticis already a core Airflow dependency. No new dep.Design decisions
responses.parse(text_format=Model)path handles schema conversion, strict-mode, and response parsing in one call. Raw JSON schema viaresponses.create(text={"format": {...}})requires the caller to define a schema name, parseoutput_textmanually, and handle refusals themselves. Pydantic is the pattern every AI-eng library (Instructor, LangChain, Pydantic-AI) wraps — starting there covers the primary use case cleanly. Raw-schema support can be added in a follow-up without breaking this API.text_format=None(default) keeps the existingstr-returning behavior. Settext_format=SomeModeland you get adict. Return-type annotation isstr | dict[str, Any]. Users who don't touch the new param see zero change.ValueErroron refusal, not silent None. Ifoutput_parsed is None, the operator raises with the response ID and status. Silently returningNonewould break downstream tasks that assume a specific shape.AGENTS.mdguidance ("prefer a Python built-in"),ValueErroris semantically correct — the model returned something that can't be converted to the requested type.create_responseuntouched. All existing tests, DAGs, and behavior are preserved. The plain-text path is not touched.What changes
providers/openai/src/airflow/providers/openai/hooks/openai.pyOpenAIHook.parse_response(input, text_format, model, **kwargs) -> ParsedResponse[Any], a thin wrapper overclient.responses.parse.providers/openai/src/airflow/providers/openai/operators/openai.pyOpenAIResponseOperatorgains atext_format: type[BaseModel] | None = Noneparameter. Return type widened tostr | dict[str, Any]. Docstring updated to explain the two paths.providers/openai/tests/unit/openai/hooks/test_openai.pytest_parse_response: hook forwardstext_format,model, and extra kwargs toconn.responses.parseand returns the SDK's return value.providers/openai/tests/unit/openai/operators/test_openai.pytest_openai_response_operator_structured_output_returns_dict: happy path — parsed model → dict viamodel_dump(),create_responsenot called.test_openai_response_operator_structured_output_refusal_raises:output_parsed=None→ValueErrorwith the response ID.test_openai_response_operator_executeis left untouched to guard the backward-compat path.providers/openai/tests/system/openai/example_openai.py# [START/END] howto_operator_openai_response_structuredblock with a PydanticPersonexample.providers/openai/docs/operators/openai.rstOpenAIResponseOperatorhow-to, with anexampleincludepointing at the new markers.No
provider.yaml/get_provider_info.pychanges needed: the registry lists python-modules, not classes, and no new module was added. No changelog edit either — perAGENTS.md, provider changelogs are regenerated fromgit logby the release manager.Testing
ruff check+ruff format --check: 29 files clean.openai.auth, andopenai.types.responses.ParsedResponse(full airflow install cannot run on Windows). Four assertions pass: hook forwards args, operator structured path returns dict + skipscreate_response, operator refusal path raisesValueError, operator backward-compat plain-text path is unchanged.test_openai_response_operator_executewas not modified, so the plain-text return path is guarded by a test the reviewer can fail by reverting the PR..github/instructions/code-review.instructions.md— no red flags (notime.time, noassertin prod, no newAirflowException, nomock.Mock()without spec, imports at top of file, session-parameter and DB-query rules N/A here).Author review
I read every line of this PR — hook, operator, tests, example DAG, docs — end
to end before pushing, and validated the SDK contract claims myself against
the installed
openaipackage (inspect.signature(Responses.parse), theParsedResponse.output_parsedattribute check, and theopenai>=2.37.0availability of both). The design calls above — Pydantic-only for this first
cut,
ValueErroron refusal instead of silentNone, opt-intext_formatparam instead of a new class — are mine, not the agent's default choices.
The generated code was iterated on until it matched what I wanted to ship.
Was generative AI tooling used to co-author this PR?
Generated-by: GitHub Copilot (Claude Opus 4.7) following the guidelines