YAshhh29 opened a new pull request, #69812:
URL: https://github.com/apache/airflow/pull/69812
The `OpenAIResponseOperator` today only returns 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 entirely. The operator's own
docstring instructs this:
> The operator is synchronous and returns the response's aggregated output
text. For
> `previous_response_id` chaining, `background=True` responses, or **access
to the full
> structured response, use `OpenAIHook` directly**.
This PR closes that gap. Pass a Pydantic `BaseModel` subclass as
`text_format` and
the operator uses the SDK's structured-output path (`responses.parse`) and
returns
`output_parsed.model_dump()` — a plain `dict`, XCom-safe, no manual
serialization
dance. When `text_format` is `None` the existing plain-text behavior is
preserved
bit-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:
1. **The API exists and is stable.**
`openai.resources.responses.Responses.parse(input, model, text_format, ...)` is
present in the pinned SDK (`openai>=2.37.0`) and returns
`ParsedResponse[TextFormatT]` with `.output_parsed` as an instance of the
passed model. Verified via `inspect.signature(Responses.parse)`.
2. **`output_parsed` is None on refusal / incomplete.** The SDK docs and
source confirm the model returning a refusal or the response being truncated
yields `output_parsed=None`. The operator raises `ValueError` in that case so
downstream tasks don't silently get `None`.
3. **`model_dump()` produces XCom-safe dicts.** Every pinned Pydantic
version supports it.
4. **`pydantic` is already a core Airflow dependency.** No new dep.
### Design decisions
- **Pydantic-only, not raw JSON schema.** The SDK's
`responses.parse(text_format=Model)` path handles schema conversion,
strict-mode, and response parsing in one call. Raw JSON schema via
`responses.create(text={"format": {...}})` requires the caller to define a
schema name, parse `output_text` manually, 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.
- **One operator, opt-in param — not a new class.** `text_format=None`
(default) keeps the existing `str`-returning behavior. Set
`text_format=SomeModel` and you get a `dict`. Return-type annotation is `str |
dict[str, Any]`. Users who don't touch the new param see zero change.
- **`ValueError` on refusal, not silent None.** If `output_parsed is None`,
the operator raises with the response ID and status. Silently returning `None`
would break downstream tasks that assume a specific shape.
- **No new exception class.** Per `AGENTS.md` guidance ("prefer a Python
built-in"), `ValueError` is semantically correct — the model returned something
that can't be converted to the requested type.
- **`create_response` untouched.** 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.py`
- New `OpenAIHook.parse_response(input, text_format, model, **kwargs) ->
ParsedResponse[Any]`, a thin wrapper over `client.responses.parse`.
- `providers/openai/src/airflow/providers/openai/operators/openai.py`
- `OpenAIResponseOperator` gains a `text_format: type[BaseModel] | None =
None` parameter. Return type widened to `str | dict[str, Any]`. Docstring
updated to explain the two paths.
- `providers/openai/tests/unit/openai/hooks/test_openai.py`
- `test_parse_response`: hook forwards `text_format`, `model`, and extra
kwargs to `conn.responses.parse` and returns the SDK's return value.
- `providers/openai/tests/unit/openai/operators/test_openai.py`
- `test_openai_response_operator_structured_output_returns_dict`: happy
path — parsed model → dict via `model_dump()`, `create_response` not called.
- `test_openai_response_operator_structured_output_refusal_raises`:
`output_parsed=None` → `ValueError` with the response ID.
- The existing `test_openai_response_operator_execute` is left untouched
to guard the backward-compat path.
- `providers/openai/tests/system/openai/example_openai.py`
- New `# [START/END] howto_operator_openai_response_structured` block with
a Pydantic `Person` example.
- `providers/openai/docs/operators/openai.rst`
- New **Structured outputs (Pydantic models)** subsection under the
existing `OpenAIResponseOperator` how-to, with an `exampleinclude` pointing at
the new markers.
No `provider.yaml` / `get_provider_info.py` changes needed: the registry
lists python-modules, not classes, and no new module was added. No changelog
edit either — per `AGENTS.md`, provider changelogs are regenerated from `git
log` by the release manager.
### Testing
- **Full-provider `ruff check` + `ruff format --check`**: 29 files clean.
- **All hook + operator tests exercised via a standalone Windows-safe
harness** stubbing airflow, `openai.auth`, and
`openai.types.responses.ParsedResponse` (full airflow install cannot run on
Windows). Four assertions pass: hook forwards args, operator structured path
returns dict + skips `create_response`, operator refusal path raises
`ValueError`, operator backward-compat plain-text path is unchanged.
- **Regression check**: the existing `test_openai_response_operator_execute`
was not modified, so the plain-text return path is guarded by a test the
reviewer can fail by reverting the PR.
- **Line-by-line reviewed by me** against every rule in
`.github/instructions/code-review.instructions.md` — no red flags (no
`time.time`, no `assert` in prod, no new `AirflowException`, no `mock.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 `openai` package (`inspect.signature(Responses.parse)`, the
`ParsedResponse.output_parsed` attribute check, and the `openai>=2.37.0`
availability of both). The design calls above — Pydantic-only for this first
cut, `ValueError` on refusal instead of silent `None`, opt-in `text_format`
param 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?
- [X] Yes — GitHub Copilot (Claude Opus 4.7)
Generated-by: GitHub Copilot (Claude Opus 4.7) following [the
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]