This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new ff471253f18 Add OpenAI Responses and Conversations support (#69070)
ff471253f18 is described below

commit ff471253f18f7652988f029cfece652d1cfaf440
Author: Kaxil Naik <[email protected]>
AuthorDate: Sat Jun 27 03:39:42 2026 +0100

    Add OpenAI Responses and Conversations support (#69070)
---
 providers/openai/docs/operators/openai.rst         | 41 ++++++++++++++
 .../src/airflow/providers/openai/hooks/openai.py   | 64 ++++++++++++++++++++++
 .../airflow/providers/openai/operators/openai.py   | 55 +++++++++++++++++++
 .../openai/tests/system/openai/example_openai.py   | 11 +++-
 .../openai/tests/unit/openai/hooks/test_openai.py  | 62 ++++++++++++++++++++-
 .../tests/unit/openai/operators/test_openai.py     | 39 +++++++++++--
 .../openai/tests/unit/openai/test_exceptions.py    |  3 +-
 7 files changed, 267 insertions(+), 8 deletions(-)

diff --git a/providers/openai/docs/operators/openai.rst 
b/providers/openai/docs/operators/openai.rst
index 10d220f6d8e..d7f88d14d1c 100644
--- a/providers/openai/docs/operators/openai.rst
+++ b/providers/openai/docs/operators/openai.rst
@@ -37,6 +37,47 @@ An example using the operator is in way:
     :start-after: [START howto_operator_openai_embedding]
     :end-before: [END howto_operator_openai_embedding]
 
+.. _howto/operator:OpenAIResponseOperator:
+
+OpenAIResponseOperator
+=======================
+
+Use the 
:class:`~airflow.providers.openai.operators.openai.OpenAIResponseOperator` to 
generate a
+model response with the OpenAI Responses API, OpenAI's recommended interface 
for text generation and
+tool use. The operator returns the response's aggregated output text.
+
+Using the Operator
+^^^^^^^^^^^^^^^^^^^
+
+The OpenAIResponseOperator requires the ``input_text`` prompt. Use the 
``conn_id`` parameter to
+specify the OpenAI connection to use, and ``response_kwargs`` to pass through 
options such as
+``tools``, ``conversation`` or ``previous_response_id``.
+
+.. exampleinclude:: /../../openai/tests/system/openai/example_openai.py
+    :language: python
+    :start-after: [START howto_operator_openai_response]
+    :end-before: [END howto_operator_openai_response]
+
+Using the OpenAIHook for Responses and Conversations
+=====================================================
+
+The :class:`~airflow.providers.openai.hooks.openai.OpenAIHook` exposes the 
Responses and
+Conversations APIs directly for use inside ``@task`` functions or custom 
operators:
+
+- Responses: ``create_response``, ``get_response``, ``delete_response`` and 
``cancel_response``
+  (the last cancels a response created with ``background=True``).
+- Conversations: ``create_conversation``, ``get_conversation``, 
``update_conversation`` and
+  ``delete_conversation``. Pass the conversation id to ``create_response`` 
(via the operator's
+  ``response_kwargs`` or the hook) to persist state across responses.
+
+For example, to create a conversation and continue it across responses:
+
+.. code-block:: python
+
+    hook = OpenAIHook()
+    conversation = hook.create_conversation()
+    hook.create_response(input="Hello", conversation=conversation.id)
+
 .. _howto/operator:OpenAITriggerBatchOperator:
 
 OpenAITriggerBatchOperator
diff --git a/providers/openai/src/airflow/providers/openai/hooks/openai.py 
b/providers/openai/src/airflow/providers/openai/hooks/openai.py
index ca80b4d3761..fbebadd493e 100644
--- a/providers/openai/src/airflow/providers/openai/hooks/openai.py
+++ b/providers/openai/src/airflow/providers/openai/hooks/openai.py
@@ -48,6 +48,8 @@ if TYPE_CHECKING:
         ChatCompletionToolMessageParam,
         ChatCompletionUserMessageParam,
     )
+    from openai.types.conversations import Conversation, 
ConversationDeletedResource
+    from openai.types.responses import Response
     from openai.types.vector_stores import VectorStoreFile, 
VectorStoreFileBatch, VectorStoreFileDeleted
 from airflow.providers.common.compat.module_loading import import_string
 from airflow.providers.common.compat.sdk import BaseHook
@@ -226,6 +228,68 @@ class OpenAIHook(BaseHook):
         response = self.conn.chat.completions.create(model=model, 
messages=messages, **kwargs)
         return response.choices
 
+    def create_response(self, input: Any, model: str = "gpt-4o-mini", 
**kwargs: Any) -> Response:
+        """
+        Create a model response using the Responses API.
+
+        :param input: Text, image, or file input(s) to the model.
+        :param model: ID of the model to use.
+        """
+        return self.conn.responses.create(model=model, input=input, **kwargs)
+
+    def get_response(self, response_id: str, **kwargs: Any) -> Response:
+        """
+        Retrieve a previously created model response.
+
+        :param response_id: The ID of the response to retrieve.
+        """
+        return self.conn.responses.retrieve(response_id, **kwargs)
+
+    def delete_response(self, response_id: str) -> None:
+        """
+        Delete a model response.
+
+        :param response_id: The ID of the response to delete.
+        """
+        self.conn.responses.delete(response_id)
+
+    def cancel_response(self, response_id: str) -> Response:
+        """
+        Cancel an in-progress response created with ``background=True``.
+
+        :param response_id: The ID of the response to cancel.
+        """
+        return self.conn.responses.cancel(response_id)
+
+    def create_conversation(self, **kwargs: Any) -> Conversation:
+        """Create a conversation that can be reused across responses to 
persist state."""
+        return self.conn.conversations.create(**kwargs)
+
+    def get_conversation(self, conversation_id: str) -> Conversation:
+        """
+        Retrieve a conversation.
+
+        :param conversation_id: The ID of the conversation to retrieve.
+        """
+        return self.conn.conversations.retrieve(conversation_id)
+
+    def update_conversation(self, conversation_id: str, metadata: dict[str, 
str]) -> Conversation:
+        """
+        Update a conversation's metadata.
+
+        :param conversation_id: The ID of the conversation to update.
+        :param metadata: Set of key-value pairs to attach to the conversation.
+        """
+        return self.conn.conversations.update(conversation_id, 
metadata=metadata)
+
+    def delete_conversation(self, conversation_id: str) -> 
ConversationDeletedResource:
+        """
+        Delete a conversation.
+
+        :param conversation_id: The ID of the conversation to delete.
+        """
+        return self.conn.conversations.delete(conversation_id)
+
     def create_assistant(self, model: str = "gpt-4o-mini", **kwargs: Any) -> 
Assistant:
         """
         Create an OpenAI assistant using the given model.
diff --git a/providers/openai/src/airflow/providers/openai/operators/openai.py 
b/providers/openai/src/airflow/providers/openai/operators/openai.py
index 192986ab456..0f040a03b55 100644
--- a/providers/openai/src/airflow/providers/openai/operators/openai.py
+++ b/providers/openai/src/airflow/providers/openai/operators/openai.py
@@ -80,6 +80,61 @@ class OpenAIEmbeddingOperator(BaseOperator):
         return embeddings
 
 
+class OpenAIResponseOperator(BaseOperator):
+    """
+    Operator that generates a model response using the OpenAI Responses API.
+
+    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 
:class:`~airflow.providers.openai.hooks.openai.OpenAIHook` directly.
+
+    :param conn_id: The OpenAI connection ID to use.
+    :param input_text: The input prompt for the model. This can be a string or 
a structured list of
+        input items.
+    :param model: The OpenAI model to use.
+    :param response_kwargs: Additional keyword arguments to pass to the OpenAI 
``create_response``
+        method (for example ``instructions``, ``tools``, ``conversation`` or 
``previous_response_id``).
+
+    .. seealso::
+        For more information on how to use this operator, take a look at the 
guide:
+        :ref:`howto/operator:OpenAIResponseOperator`
+        For possible options, see:
+        https://platform.openai.com/docs/api-reference/responses/create
+    """
+
+    template_fields: Sequence[str] = ("input_text",)
+
+    def __init__(
+        self,
+        conn_id: str,
+        input_text: str | list[Any],
+        model: str = "gpt-4o-mini",
+        response_kwargs: dict | None = None,
+        **kwargs: Any,
+    ):
+        super().__init__(**kwargs)
+        self.conn_id = conn_id
+        self.input_text = input_text
+        self.model = model
+        self.response_kwargs = response_kwargs or {}
+
+    @cached_property
+    def hook(self) -> OpenAIHook:
+        """Return an instance of the OpenAIHook."""
+        return OpenAIHook(conn_id=self.conn_id)
+
+    def execute(self, context: Context) -> str:
+        response = self.hook.create_response(input=self.input_text, 
model=self.model, **self.response_kwargs)
+        if response.status != "completed":
+            self.log.warning(
+                "Response %s ended with status %s; the returned output text 
may be empty.",
+                response.id,
+                response.status,
+            )
+        self.log.info("Generated response %s", response.id)
+        return response.output_text
+
+
 class OpenAITriggerBatchOperator(BaseOperator):
     """
     Operator that triggers an OpenAI Batch API endpoint and waits for the 
batch to complete.
diff --git a/providers/openai/tests/system/openai/example_openai.py 
b/providers/openai/tests/system/openai/example_openai.py
index 6cbbe2b47f3..e03bc639745 100644
--- a/providers/openai/tests/system/openai/example_openai.py
+++ b/providers/openai/tests/system/openai/example_openai.py
@@ -22,7 +22,7 @@ import pendulum
 # If you only need Airflow 3+, you can use: from airflow.sdk import dag, task
 from airflow.providers.common.compat.sdk import dag, task
 from airflow.providers.openai.hooks.openai import OpenAIHook
-from airflow.providers.openai.operators.openai import OpenAIEmbeddingOperator
+from airflow.providers.openai.operators.openai import OpenAIEmbeddingOperator, 
OpenAIResponseOperator
 
 
 def input_text_callable(
@@ -100,6 +100,15 @@ def example_openai_dag():
     )
     # [END howto_operator_openai_embedding]
 
+    # [START howto_operator_openai_response]
+    OpenAIResponseOperator(
+        task_id="openai_response",
+        conn_id="openai_default",
+        input_text="Write a haiku about data pipelines.",
+        response_kwargs={"instructions": "You are a helpful assistant."},
+    )
+    # [END howto_operator_openai_response]
+
     create_embeddings_using_hook()
 
 
diff --git a/providers/openai/tests/unit/openai/hooks/test_openai.py 
b/providers/openai/tests/unit/openai/hooks/test_openai.py
index 6cee1244e2c..9b4760953d4 100644
--- a/providers/openai/tests/unit/openai/hooks/test_openai.py
+++ b/providers/openai/tests/unit/openai/hooks/test_openai.py
@@ -17,9 +17,10 @@
 from __future__ import annotations
 
 import os
-from unittest.mock import mock_open, patch
+from unittest.mock import MagicMock, mock_open, patch
 
 import pytest
+from openai import OpenAI
 from openai.pagination import SyncCursorPage
 from openai.types import (
     Batch,
@@ -82,7 +83,10 @@ def mock_openai_connection():
 
 @pytest.fixture
 def mock_openai_hook(mock_openai_connection):
-    with patch("airflow.providers.openai.hooks.openai.OpenAI"):
+    # spec=OpenAI guards top-level namespace access (an unknown attribute on 
the client fails);
+    # the method surface (``conn.responses.create``) is type-checked by mypy 
against the SDK stubs.
+    with patch("airflow.providers.openai.hooks.openai.OpenAI") as mock_client:
+        mock_client.return_value = MagicMock(spec=OpenAI)
         yield OpenAIHook(conn_id=mock_openai_connection.conn_id)
 
 
@@ -303,6 +307,60 @@ def test_create_chat_completion(mock_openai_hook, 
mock_completion):
     assert choice.message.content == "Hello there, how may I assist you today?"
 
 
+def test_create_response(mock_openai_hook):
+    expected = mock_openai_hook.conn.responses.create.return_value
+    result = mock_openai_hook.create_response(input="Hello", model=MODEL)
+    
mock_openai_hook.conn.responses.create.assert_called_once_with(model=MODEL, 
input="Hello")
+    assert result is expected
+
+
+def test_get_response(mock_openai_hook):
+    expected = mock_openai_hook.conn.responses.retrieve.return_value
+    result = mock_openai_hook.get_response("resp_123")
+    
mock_openai_hook.conn.responses.retrieve.assert_called_once_with("resp_123")
+    assert result is expected
+
+
+def test_delete_response(mock_openai_hook):
+    mock_openai_hook.delete_response("resp_123")
+    mock_openai_hook.conn.responses.delete.assert_called_once_with("resp_123")
+
+
+def test_cancel_response(mock_openai_hook):
+    expected = mock_openai_hook.conn.responses.cancel.return_value
+    result = mock_openai_hook.cancel_response("resp_123")
+    mock_openai_hook.conn.responses.cancel.assert_called_once_with("resp_123")
+    assert result is expected
+
+
+def test_create_conversation(mock_openai_hook):
+    expected = mock_openai_hook.conn.conversations.create.return_value
+    result = mock_openai_hook.create_conversation(metadata={"topic": "demo"})
+    
mock_openai_hook.conn.conversations.create.assert_called_once_with(metadata={"topic":
 "demo"})
+    assert result is expected
+
+
+def test_get_conversation(mock_openai_hook):
+    expected = mock_openai_hook.conn.conversations.retrieve.return_value
+    result = mock_openai_hook.get_conversation("conv_123")
+    
mock_openai_hook.conn.conversations.retrieve.assert_called_once_with("conv_123")
+    assert result is expected
+
+
+def test_update_conversation(mock_openai_hook):
+    expected = mock_openai_hook.conn.conversations.update.return_value
+    result = mock_openai_hook.update_conversation("conv_123", 
metadata={"topic": "demo"})
+    
mock_openai_hook.conn.conversations.update.assert_called_once_with("conv_123", 
metadata={"topic": "demo"})
+    assert result is expected
+
+
+def test_delete_conversation(mock_openai_hook):
+    expected = mock_openai_hook.conn.conversations.delete.return_value
+    result = mock_openai_hook.delete_conversation("conv_123")
+    
mock_openai_hook.conn.conversations.delete.assert_called_once_with("conv_123")
+    assert result is expected
+
+
 def test_create_assistant(mock_openai_hook, mock_assistant):
     mock_openai_hook.conn.beta.assistants.create.return_value = mock_assistant
     assistant = mock_openai_hook.create_assistant(
diff --git a/providers/openai/tests/unit/openai/operators/test_openai.py 
b/providers/openai/tests/unit/openai/operators/test_openai.py
index f85f3dd3d70..954306d42a5 100644
--- a/providers/openai/tests/unit/openai/operators/test_openai.py
+++ b/providers/openai/tests/unit/openai/operators/test_openai.py
@@ -20,9 +20,15 @@ from unittest.mock import Mock
 
 import pytest
 from openai.types.batch import Batch
+from openai.types.responses import Response
 
 from airflow.providers.common.compat.sdk import Context, TaskDeferred
-from airflow.providers.openai.operators.openai import OpenAIEmbeddingOperator, 
OpenAITriggerBatchOperator
+from airflow.providers.openai.hooks.openai import OpenAIHook
+from airflow.providers.openai.operators.openai import (
+    OpenAIEmbeddingOperator,
+    OpenAIResponseOperator,
+    OpenAITriggerBatchOperator,
+)
 from airflow.providers.openai.triggers.openai import OpenAIBatchTrigger
 
 openai = pytest.importorskip("openai")
@@ -50,7 +56,7 @@ def test_execute_with_input_text():
     operator = OpenAIEmbeddingOperator(
         task_id=TASK_ID, conn_id=CONN_ID, model="test_model", input_text="Test 
input text"
     )
-    mock_hook_instance = Mock()
+    mock_hook_instance = Mock(spec=OpenAIHook)
     mock_hook_instance.create_embeddings.return_value = [1.0, 2.0, 3.0]
     operator.hook = mock_hook_instance
 
@@ -73,6 +79,31 @@ def test_execute_with_invalid_input(invalid_input):
         operator.execute(context)
 
 
+def test_openai_response_operator_execute():
+    operator = OpenAIResponseOperator(
+        task_id=TASK_ID,
+        conn_id=CONN_ID,
+        input_text="Write a haiku.",
+        model="test_model",
+        response_kwargs={"instructions": "Be concise.", 
"previous_response_id": "resp_prev"},
+    )
+    mock_hook_instance = Mock(spec=OpenAIHook)
+    mock_hook_instance.create_response.return_value = Mock(
+        spec=Response, output_text="haiku text", id="resp_123", 
status="completed"
+    )
+    operator.hook = mock_hook_instance
+
+    result = operator.execute(Context())
+
+    assert result == "haiku text"
+    mock_hook_instance.create_response.assert_called_once_with(
+        input="Write a haiku.",
+        model="test_model",
+        instructions="Be concise.",
+        previous_response_id="resp_prev",
+    )
+
+
 @pytest.mark.parametrize("wait_for_completion", [True, False])
 def test_openai_trigger_batch_operator_not_deferred(mock_batch, 
wait_for_completion):
     operator = OpenAITriggerBatchOperator(
@@ -83,7 +114,7 @@ def 
test_openai_trigger_batch_operator_not_deferred(mock_batch, wait_for_complet
         wait_for_completion=wait_for_completion,
         deferrable=False,
     )
-    mock_hook_instance = Mock()
+    mock_hook_instance = Mock(spec=OpenAIHook)
     mock_hook_instance.get_batch.return_value = mock_batch
     mock_hook_instance.create_batch.return_value = mock_batch
     operator.hook = mock_hook_instance
@@ -103,7 +134,7 @@ def 
test_openai_trigger_batch_operator_with_deferred(mock_batch, wait_for_comple
         deferrable=True,
         wait_for_completion=wait_for_completion,
     )
-    mock_hook_instance = Mock()
+    mock_hook_instance = Mock(spec=OpenAIHook)
     mock_hook_instance.get_batch.return_value = mock_batch
     mock_hook_instance.create_batch.return_value = mock_batch
     operator.hook = mock_hook_instance
diff --git a/providers/openai/tests/unit/openai/test_exceptions.py 
b/providers/openai/tests/unit/openai/test_exceptions.py
index 38f6d38a509..fabaad35343 100644
--- a/providers/openai/tests/unit/openai/test_exceptions.py
+++ b/providers/openai/tests/unit/openai/test_exceptions.py
@@ -22,6 +22,7 @@ from unittest.mock import Mock
 import pytest
 
 from airflow.providers.openai.exceptions import OpenAIBatchJobException, 
OpenAIBatchTimeout
+from airflow.providers.openai.hooks.openai import OpenAIHook
 
 
 @pytest.mark.parametrize(
@@ -32,7 +33,7 @@ from airflow.providers.openai.exceptions import 
OpenAIBatchJobException, OpenAIB
     ],
 )
 def test_wait_for_batch_raise_exception(exception_class):
-    mock_hook_instance = Mock()
+    mock_hook_instance = Mock(spec=OpenAIHook)
     mock_hook_instance.wait_for_batch.side_effect = exception_class
     hook = mock_hook_instance
     with pytest.raises(exception_class):

Reply via email to