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

Lee-W 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 f5172d84929 Make OpenAI Responses token ceilings templated and surface 
truncation (#72150)
f5172d84929 is described below

commit f5172d84929e805d24362b955b89da9b2ebbc887
Author: Wei Lee <[email protected]>
AuthorDate: Tue Sep 15 23:36:07 2026 +0800

    Make OpenAI Responses token ceilings templated and surface truncation 
(#72150)
---
 providers/openai/docs/operators/openai.rst         |  30 +-
 .../airflow/providers/openai/operators/openai.py   | 146 ++++++++-
 .../openai/tests/system/openai/example_openai.py   |  12 +
 .../tests/unit/openai/operators/test_openai.py     | 360 ++++++++++++++++++++-
 4 files changed, 540 insertions(+), 8 deletions(-)

diff --git a/providers/openai/docs/operators/openai.rst 
b/providers/openai/docs/operators/openai.rst
index b6bd4ab5b3f..06689ac041d 100644
--- a/providers/openai/docs/operators/openai.rst
+++ b/providers/openai/docs/operators/openai.rst
@@ -53,6 +53,27 @@ The OpenAIResponseOperator requires the ``input_text`` 
prompt. Use the ``conn_id
 specify the OpenAI connection to use, and ``response_kwargs`` to pass through 
options such as
 ``tools``, ``conversation`` or ``previous_response_id``.
 
+Use ``max_output_tokens`` and ``max_tool_calls`` to cap generation per run -- 
both are templated,
+so a ceiling can vary by environment or Dag run without hardcoding it. 
``max_output_tokens`` caps
+the number of tokens generated; ``max_tool_calls`` caps the number of built-in 
tool calls the model
+may make. Both limits are enforced by the OpenAI API itself; OpenAI exposes no 
monetary cost limit
+on the Responses API, so this operator has no cost cap. For a monetary limit, 
use
+:doc:`apache-airflow-providers-common-ai:index` instead. Hitting 
``max_output_tokens`` does not
+fail the request: the response comes back with ``status="incomplete"``, so 
``return_value`` will
+not raise -- but it is not guaranteed to be truncated text either. A reasoning 
model can spend
+the entire ceiling on reasoning tokens and return an empty ``output_text``, in 
which case
+``return_value`` is an empty string. Hitting ``max_tool_calls`` is different: 
the OpenAI API
+silently drops any tool calls beyond the ceiling without changing ``status`` 
or setting
+``incomplete_details`` -- there is no log warning and no signal in 
``return_value``, so a run
+truncated by ``max_tool_calls`` looks identical to a clean run.
+
+A rendered ``max_output_tokens`` or ``max_tool_calls`` that is blank or 
whitespace-only -- for
+example ``max_output_tokens="{{ params.tokens | default('', true) }}"`` when 
``params.tokens`` is
+unset -- is treated as "no ceiling for this run" rather than raising. This 
only applies when the same run does
+not also set the corresponding key in ``response_kwargs``: the 
mutually-exclusive-with-``response_kwargs``
+check happens when the operator is constructed and fires regardless of what 
the template later
+renders to.
+
 .. exampleinclude:: /../../openai/tests/system/openai/example_openai.py
     :language: python
     :start-after: [START howto_operator_openai_response]
@@ -103,8 +124,13 @@ know about yet. Options worth knowing about:
   by ``execute``. Use ``OpenAIHook`` directly to access it.
 - ``metadata``: a mapping of key-value pairs attached to the response for your 
own bookkeeping.
 - ``max_output_tokens``: an upper bound on the number of tokens the model can 
generate, including
-  reasoning tokens as well as visible output tokens.
-- ``max_tool_calls``: an upper bound on the number of built-in tool calls the 
model can make.
+  reasoning tokens as well as visible output tokens. Prefer the operator's own 
``max_output_tokens``
+  parameter (see above) instead of setting this key here: the operator 
parameter is templated, this
+  ``response_kwargs`` key is not, and setting the same ceiling in both places 
raises when the
+  operator is constructed.
+- ``max_tool_calls``: an upper bound on the number of built-in tool calls the 
model can make. Same
+  trade-off as ``max_output_tokens`` above: prefer the operator's own, 
templated ``max_tool_calls``
+  parameter instead of this non-templated ``response_kwargs`` key.
 
 .. note::
 
diff --git a/providers/openai/src/airflow/providers/openai/operators/openai.py 
b/providers/openai/src/airflow/providers/openai/operators/openai.py
index 1f12593e23b..0dad7792f18 100644
--- a/providers/openai/src/airflow/providers/openai/operators/openai.py
+++ b/providers/openai/src/airflow/providers/openai/operators/openai.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 
 from collections.abc import Sequence
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any, ClassVar, Literal
 
 from airflow.providers.common.compat.sdk import BaseOperator, conf
 from airflow.providers.openai.exceptions import OpenAIBatchJobException
@@ -87,6 +87,18 @@ class OpenAIResponseOperator(BaseOperator):
     ``previous_response_id`` chaining, ``background=True`` responses, or 
access to the full
     structured response, use 
:class:`~airflow.providers.openai.hooks.openai.OpenAIHook` directly.
 
+    ``max_output_tokens`` caps the number of tokens generated for the 
response; ``max_tool_calls``
+    caps the number of built-in tool calls the model may make. Both limits are 
enforced by the
+    OpenAI API itself -- OpenAI exposes no monetary cost limit on the 
Responses API, so this
+    operator has no cost cap. For a monetary limit, use
+    :doc:`apache-airflow-providers-common-ai:index` instead. When 
``max_output_tokens`` is hit, the
+    request does not fail: the response comes back with 
``status="incomplete"`` -- but
+    ``output_text`` is not guaranteed to contain any content, since a 
reasoning model can spend
+    the entire ceiling on reasoning tokens without producing visible output. 
Hitting
+    ``max_tool_calls`` is different: the OpenAI SDK documents it as silently 
dropping further
+    tool calls, with no ``status`` change and no ``incomplete_details`` -- a 
run truncated this
+    way looks identical to a clean one in both the logs and ``return_value``.
+
     :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.
@@ -97,7 +109,25 @@ class OpenAIResponseOperator(BaseOperator):
         completes, so this operator logs a warning and the returned output 
text may be empty, while
         ``stream=True`` returns an object without ``status`` or 
``output_text``, so the task raises
         ``AttributeError``. See :ref:`howto/operator:OpenAIResponseOperator` 
for these and other
-        options this operator can pass through, such as ``truncation`` and 
``max_output_tokens``.
+        options this operator can pass through, such as ``truncation`` and 
``metadata``.
+    :param max_output_tokens: Optional upper bound on the number of tokens 
generated for the
+        response. Templated, so it renders to a string; accepts an ``int`` or 
a string containing one.
+        Must be a positive integer -- an invalid value raises instead of 
silently disabling the
+        ceiling. A literal ``bool``, ``float``, or ``int`` value is validated 
when the operator is
+        constructed; any other non-string literal (for example ``Decimal`` or 
``Fraction``) is
+        coerced -- and rejected if invalid -- only when the task executes. A 
string value --
+        whether a template or a plain literal string -- is also validated when 
the task executes,
+        after templating has resolved it. A blank or whitespace-only rendered 
value (for example
+        ``{{ params.tokens | default('', true) }}`` rendering to ``''``) is 
treated as unset,
+        disabling the ceiling; the literal strings ``"None"``, ``"none"`` and 
``"null"`` are **not**
+        treated as blank and still raise. A value that was supplied but 
resolves to ``None`` (for
+        example an unresolved ``XComArg``, or a Jinja-native-rendered null) 
also raises -- it is not
+        treated as unset. Mutually exclusive with ``max_output_tokens`` in 
``response_kwargs`` --
+        this is checked when the operator is constructed, regardless of what 
the templated value
+        later renders to.
+    :param max_tool_calls: Optional upper bound on the number of built-in tool 
calls the model may
+        make while generating the response. Same templating, type, validation, 
blank-as-unset, and
+        mutual-exclusion rules as ``max_output_tokens``.
 
     .. seealso::
         For more information on how to use this operator, take a look at the 
guide:
@@ -106,7 +136,9 @@ class OpenAIResponseOperator(BaseOperator):
         https://platform.openai.com/docs/api-reference/responses/create
     """
 
-    template_fields: Sequence[str] = ("input_text",)
+    template_fields: Sequence[str] = ("input_text", "max_output_tokens", 
"max_tool_calls")
+
+    _TOKEN_CEILING_PARAM_NAMES: ClassVar[tuple[str, ...]] = 
("max_output_tokens", "max_tool_calls")
 
     def __init__(
         self,
@@ -114,6 +146,9 @@ class OpenAIResponseOperator(BaseOperator):
         input_text: str | list[Any],
         model: str = "gpt-4o-mini",
         response_kwargs: dict | None = None,
+        *,
+        max_output_tokens: int | str | None = None,
+        max_tool_calls: int | str | None = None,
         **kwargs: Any,
     ):
         super().__init__(**kwargs)
@@ -121,15 +156,116 @@ class OpenAIResponseOperator(BaseOperator):
         self.input_text = input_text
         self.model = model
         self.response_kwargs = response_kwargs or {}
+        self.max_output_tokens = max_output_tokens
+        self.max_tool_calls = max_tool_calls
+        self._supplied_ceilings: frozenset[str] = frozenset(
+            name for name in self._TOKEN_CEILING_PARAM_NAMES if getattr(self, 
name) is not None
+        )
+        self._validate_no_response_kwargs_conflict()
+        self._validate_literal_ceiling_values()
+
+    def _validate_no_response_kwargs_conflict(self) -> None:
+        """Reject a ceiling set both as an operator argument and in 
``response_kwargs``."""
+        for param_name in self._TOKEN_CEILING_PARAM_NAMES:
+            value = getattr(self, param_name)
+            if value is not None and param_name in self.response_kwargs:
+                raise ValueError(
+                    f"Task {self.task_id!r}: {param_name!r} was set both as an 
operator argument "
+                    "and in 'response_kwargs'; set it in only one place."
+                )
+
+    def _validate_literal_ceiling_values(self) -> None:
+        """
+        Eagerly validate a ceiling value that is already a final literal, not 
a template.
+
+        Only ``bool``, ``float``, and ``int`` are recognized as literals here 
-- these are the raw
+        values passed at construction, before any templating runs, so an 
invalid one is rejected
+        when the operator is constructed instead of surfacing only when the 
task runs. Anything
+        else (``str`` templates awaiting ``render_template_fields()``, or 
template values such as
+        ``XComArg`` that resolve later -- including a ``bool``, ``float``, or 
``int`` produced by
+        Jinja's native rendering with ``render_template_as_native_obj=True``) 
must wait for
+        ``_build_response_kwargs()`` at ``execute()`` time.
+        """
+        for param_name in self._TOKEN_CEILING_PARAM_NAMES:
+            value = getattr(self, param_name)
+            if value is not None and isinstance(value, (bool, float, int)):
+                self._coerce_token_ceiling(param_name, value)
 
     @cached_property
     def hook(self) -> OpenAIHook:
         """Return an instance of the OpenAIHook."""
         return OpenAIHook(conn_id=self.conn_id)
 
+    @staticmethod
+    def _coerce_token_ceiling(param_name: str, value: int | float | str) -> 
int:
+        """Coerce a templated token-ceiling argument to a positive int, or 
raise ``ValueError``."""
+        # bool is an int subclass (isinstance(True, int) is True) and must be 
rejected before the
+        # allowlist check below. Only int and str are accepted as real values 
to coerce; anything
+        # else -- float, Decimal, Fraction, or any other numeric type -- is 
rejected here instead
+        # of being handed to int(), since int() silently truncates those (e.g. 
int(10.5) == 10,
+        # int(Decimal("10.5")) == 10) rather than raising. Such values can 
reach here as real Python
+        # objects, not just strings, when a Dag uses 
render_template_as_native_obj=True.
+        if isinstance(value, bool):
+            raise ValueError(f"{param_name!r} must be an integer, got 
{value!r}.")
+        if not isinstance(value, (int, str)):
+            raise ValueError(f"{param_name!r} must be an integer, got 
{value!r}.")
+        try:
+            coerced = int(value)
+        except (TypeError, ValueError):
+            raise ValueError(f"{param_name!r} must be an integer, got 
{value!r}.")
+        if coerced <= 0:
+            raise ValueError(f"{param_name!r} must be a positive integer, got 
{coerced}.")
+        return coerced
+
+    def _build_response_kwargs(self) -> dict[str, Any]:
+        """Merge the token-ceiling arguments into ``response_kwargs``, 
skipping unset ceilings."""
+        response_kwargs = dict(self.response_kwargs)
+        for param_name in self._TOKEN_CEILING_PARAM_NAMES:
+            value = getattr(self, param_name)
+            if param_name not in self._supplied_ceilings:
+                continue
+            # Blank means unset; the response_kwargs conflict was already 
rejected in __init__.
+            if isinstance(value, str) and value.strip() == "":
+                continue
+            if value is None:
+                raise ValueError(
+                    f"{param_name!r} was supplied but resolved to None (e.g. 
an unresolved "
+                    "XComArg, or a Jinja-native-rendered null); pass a 
positive integer, or "
+                    "leave the argument unset entirely to disable the ceiling."
+                )
+            response_kwargs[param_name] = 
self._coerce_token_ceiling(param_name, value)
+        return response_kwargs
+
     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":
+        response = self.hook.create_response(
+            input=self.input_text, model=self.model, 
**self._build_response_kwargs()
+        )
+        if response.status == "incomplete":
+            reason = response.incomplete_details.reason if 
response.incomplete_details else None
+            if reason and response.output_text:
+                # Any reason -- including max_output_tokens -- can fire before 
any output text
+                # is produced (e.g. a reasoning model spends the whole ceiling 
on reasoning
+                # tokens), so whether truncated content actually exists is 
decided by looking at
+                # output_text itself, not by the reason string.
+                self.log.warning(
+                    "Response %s is incomplete (incomplete_details.reason=%s); 
the returned output "
+                    "text is truncated, not empty.",
+                    response.id,
+                    reason,
+                )
+            elif reason:
+                self.log.warning(
+                    "Response %s is incomplete (incomplete_details.reason=%s); 
the returned output "
+                    "text may be empty.",
+                    response.id,
+                    reason,
+                )
+            else:
+                self.log.warning(
+                    "Response %s is incomplete; the returned output text may 
be truncated or empty.",
+                    response.id,
+                )
+        elif response.status != "completed":
             self.log.warning(
                 "Response %s ended with status %s; the returned output text 
may be empty.",
                 response.id,
diff --git a/providers/openai/tests/system/openai/example_openai.py 
b/providers/openai/tests/system/openai/example_openai.py
index e03bc639745..e14c89e6db0 100644
--- a/providers/openai/tests/system/openai/example_openai.py
+++ b/providers/openai/tests/system/openai/example_openai.py
@@ -107,6 +107,18 @@ def example_openai_dag():
         input_text="Write a haiku about data pipelines.",
         response_kwargs={"instructions": "You are a helpful assistant."},
     )
+
+    # ``max_output_tokens`` is templated, so a ceiling can vary by Dag run 
without hardcoding it.
+    # This Dag does not declare a ``tokens`` param, so ``params.tokens`` is 
undefined at render
+    # time. ``| default('', true)`` renders undefined *and* None values to 
``''`` (treated as "no
+    # ceiling"); the more common ``{{ params.tokens or '' }}`` idiom would 
instead raise
+    # ``UndefinedError`` under Airflow's default ``StrictUndefined`` template 
behavior.
+    OpenAIResponseOperator(
+        task_id="openai_response_with_token_ceiling",
+        conn_id="openai_default",
+        input_text="Write a haiku about data pipelines.",
+        max_output_tokens="{{ params.tokens | default('', true) }}",
+    )
     # [END howto_operator_openai_response]
 
     create_embeddings_using_hook()
diff --git a/providers/openai/tests/unit/openai/operators/test_openai.py 
b/providers/openai/tests/unit/openai/operators/test_openai.py
index 0ec78bb182c..d4ba4403840 100644
--- a/providers/openai/tests/unit/openai/operators/test_openai.py
+++ b/providers/openai/tests/unit/openai/operators/test_openai.py
@@ -16,13 +16,17 @@
 # under the License.
 from __future__ import annotations
 
+from decimal import Decimal
+from fractions import Fraction
 from unittest.mock import Mock
 
+import jinja2
 import pytest
 from openai.types.batch import Batch
 from openai.types.responses import Response
+from openai.types.responses.response import IncompleteDetails
 
-from airflow.providers.common.compat.sdk import Context, TaskDeferred
+from airflow.providers.common.compat.sdk import DAG, BaseOperator, Context, 
TaskDeferred, XComArg
 from airflow.providers.openai.exceptions import OpenAIBatchJobException, 
OpenAITriggerEventError
 from airflow.providers.openai.hooks.openai import OpenAIHook
 from airflow.providers.openai.operators.openai import (
@@ -105,6 +109,360 @@ def test_openai_response_operator_execute():
     )
 
 
+def _build_completed_response(**overrides):
+    defaults = {"output_text": "haiku text", "id": "resp_123", "status": 
"completed"}
+    return Mock(spec=Response, **{**defaults, **overrides})
+
+
+class TestOpenAIResponseOperatorTokenCeilings:
+    @pytest.mark.parametrize(
+        ("kwargs", "expected_extra"),
+        [
+            pytest.param({"max_output_tokens": 100}, {"max_output_tokens": 
100}, id="max_output_tokens-int"),
+            pytest.param({"max_tool_calls": 5}, {"max_tool_calls": 5}, 
id="max_tool_calls-int"),
+            pytest.param(
+                {"max_output_tokens": "100"}, {"max_output_tokens": 100}, 
id="max_output_tokens-numeric-str"
+            ),
+            pytest.param({"max_tool_calls": "5"}, {"max_tool_calls": 5}, 
id="max_tool_calls-numeric-str"),
+            pytest.param(
+                {"max_output_tokens": 100, "max_tool_calls": 5},
+                {"max_output_tokens": 100, "max_tool_calls": 5},
+                id="both",
+            ),
+        ],
+    )
+    def test_valid_ceiling_forwarded_as_int(self, kwargs, expected_extra):
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID, conn_id=CONN_ID, input_text="Write a haiku.", 
**kwargs
+        )
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response()
+        operator.hook = mock_hook_instance
+
+        operator.execute(Context())
+
+        mock_hook_instance.create_response.assert_called_once_with(
+            input="Write a haiku.", model="gpt-4o-mini", **expected_extra
+        )
+
+    @pytest.mark.parametrize(
+        "invalid_value",
+        [
+            pytest.param("not-a-number", id="non-integer-string"),
+            pytest.param("-5", id="negative-string"),
+            pytest.param("None", id="literal-none-string"),
+            pytest.param(Decimal("10.5"), id="decimal"),
+            pytest.param(Fraction(21, 2), id="fraction"),
+        ],
+    )
+    @pytest.mark.parametrize("param_name", ["max_output_tokens", 
"max_tool_calls"])
+    def test_invalid_ceiling_raises_before_request(self, param_name, 
invalid_value):
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID, conn_id=CONN_ID, input_text="Write a haiku.", 
**{param_name: invalid_value}
+        )
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        operator.hook = mock_hook_instance
+
+        with pytest.raises(ValueError, match=param_name):
+            operator.execute(Context())
+
+        mock_hook_instance.create_response.assert_not_called()
+
+    @pytest.mark.parametrize(
+        "invalid_value",
+        [
+            pytest.param(0, id="zero"),
+            pytest.param(-1, id="negative"),
+            pytest.param(10.5, id="float"),
+            pytest.param(True, id="bool-true"),
+            pytest.param(False, id="bool-false"),
+        ],
+    )
+    @pytest.mark.parametrize("param_name", ["max_output_tokens", 
"max_tool_calls"])
+    def test_non_string_invalid_ceiling_raises_at_construction(self, 
param_name, invalid_value):
+        with pytest.raises(ValueError, match=param_name):
+            OpenAIResponseOperator(
+                task_id=TASK_ID,
+                conn_id=CONN_ID,
+                input_text="Write a haiku.",
+                **{param_name: invalid_value},
+            )
+
+    @pytest.mark.parametrize(
+        "operator_value",
+        [
+            pytest.param(100, id="int"),
+            pytest.param("", id="blank"),
+        ],
+    )
+    @pytest.mark.parametrize("param_name", ["max_output_tokens", 
"max_tool_calls"])
+    def test_ceiling_conflicting_with_response_kwargs_raises(self, param_name, 
operator_value):
+        # A blank operator_value must still conflict with response_kwargs; 
that's checked at
+        # construction time, before rendering. pytest.raises() itself fails 
with "DID NOT RAISE"
+        # if construction succeeded, so there's no operator instance 
afterwards to assert
+        # anything further against.
+        with pytest.raises(ValueError, match=param_name):
+            OpenAIResponseOperator(
+                task_id=TASK_ID,
+                conn_id=CONN_ID,
+                input_text="Write a haiku.",
+                response_kwargs={param_name: 50},
+                **{param_name: operator_value},
+            )
+
+    def test_conflict_error_precedes_literal_type_error(self):
+        # 0 is both invalid on its own (not positive) and conflicting with 
response_kwargs; the
+        # conflict message must win, since fixing the duplicate is the 
actionable first step.
+        with pytest.raises(ValueError, match="was set both as an operator 
argument"):
+            OpenAIResponseOperator(
+                task_id=TASK_ID,
+                conn_id=CONN_ID,
+                input_text="Write a haiku.",
+                response_kwargs={"max_output_tokens": 50},
+                max_output_tokens=0,
+            )
+
+    def test_xcom_arg_ceiling_does_not_fail_on_construction(self):
+        with DAG("test_dag", schedule=None) as dag:
+            upstream = BaseOperator(task_id="upstream")
+
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID,
+            conn_id=CONN_ID,
+            input_text="Write a haiku.",
+            max_output_tokens=upstream.output,
+            dag=dag,
+        )
+
+        assert isinstance(operator.max_output_tokens, XComArg)
+
+    @pytest.mark.parametrize(
+        "blank_value", [pytest.param("", id="empty"), pytest.param("   ", 
id="whitespace")]
+    )
+    @pytest.mark.parametrize("param_name", ["max_output_tokens", 
"max_tool_calls"])
+    def test_blank_ceiling_is_treated_as_unset(self, param_name, blank_value):
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID, conn_id=CONN_ID, input_text="Write a haiku.", 
**{param_name: blank_value}
+        )
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response()
+        operator.hook = mock_hook_instance
+
+        operator.execute(Context())
+
+        call_kwargs = mock_hook_instance.create_response.call_args.kwargs
+        assert param_name not in call_kwargs
+
+    def test_max_output_tokens_and_max_tool_calls_are_templated(self):
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID,
+            conn_id=CONN_ID,
+            input_text="Write a haiku.",
+            max_output_tokens="{{ params.tokens }}",
+            max_tool_calls="{{ params.calls }}",
+        )
+
+        operator.render_template_fields(Context(params={"tokens": 100, 
"calls": 5}))
+
+        assert operator.max_output_tokens == "100"
+        assert operator.max_tool_calls == "5"
+        assert "max_output_tokens" in operator.template_fields
+        assert "max_tool_calls" in operator.template_fields
+
+        # The rendered strings must still make it to the SDK as real ints, not 
left as strings.
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response()
+        operator.hook = mock_hook_instance
+
+        operator.execute(Context())
+
+        call_kwargs = mock_hook_instance.create_response.call_args.kwargs
+        for key, expected in (
+            ("max_output_tokens", 100),
+            ("max_tool_calls", 5),
+        ):
+            assert call_kwargs[key] == expected
+            assert isinstance(call_kwargs[key], int)
+            assert not isinstance(call_kwargs[key], bool)
+
+    @pytest.mark.parametrize("param_name", ["max_output_tokens", 
"max_tool_calls"])
+    def test_supplied_xcom_arg_ceiling_resolving_to_none_raises(self, 
param_name):
+        # An XComArg bound at construction time (e.g. upstream.output) is not 
"unset" -- if
+        # rendering it later resolves to None (no XCom was ever pushed), that 
must raise instead
+        # of silently disabling the ceiling.
+        with DAG("test_dag", schedule=None) as dag:
+            upstream = BaseOperator(task_id="upstream")
+
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID,
+            conn_id=CONN_ID,
+            input_text="Write a haiku.",
+            dag=dag,
+            **{param_name: upstream.output},
+        )
+
+        mock_ti = Mock()
+        mock_ti.xcom_pull.return_value = None
+        # Airflow 2's XComArg.resolve() reads context["expanded_ti_count"] 
unconditionally, so the
+        # key has to be present for this to render under the compatibility 
test suite.
+        operator.render_template_fields(Context(ti=mock_ti, 
expanded_ti_count=None))
+
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        operator.hook = mock_hook_instance
+
+        with pytest.raises(ValueError, match=param_name):
+            operator.execute(Context())
+
+        mock_hook_instance.create_response.assert_not_called()
+
+    @pytest.mark.parametrize("param_name", ["max_output_tokens", 
"max_tool_calls"])
+    def test_native_rendered_none_ceiling_raises(self, param_name):
+        # render_template_as_native_obj=True can render a Jinja template 
straight to a real
+        # None -- that is also "supplied but resolved to None", not "unset".
+        param_key = "tokens" if param_name == "max_output_tokens" else "calls"
+        with DAG("test_dag", schedule=None, 
render_template_as_native_obj=True):
+            operator = OpenAIResponseOperator(
+                task_id=TASK_ID,
+                conn_id=CONN_ID,
+                input_text="Write a haiku.",
+                **{param_name: f"{{{{ params.{param_key} }}}}"},
+            )
+
+        operator.render_template_fields(Context(params={param_key: None}))
+
+        assert getattr(operator, param_name) is None
+
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        operator.hook = mock_hook_instance
+
+        with pytest.raises(ValueError, match=param_name):
+            operator.execute(Context())
+
+        mock_hook_instance.create_response.assert_not_called()
+
+    def test_or_fallback_idiom_raises_under_strict_undefined_dag_binding(self):
+        with DAG("test_dag", schedule=None) as dag:
+            operator = OpenAIResponseOperator(
+                task_id=TASK_ID,
+                conn_id=CONN_ID,
+                input_text="Write a haiku.",
+                max_output_tokens="{{ params.tokens or '' }}",
+                dag=dag,
+            )
+
+        with pytest.raises(jinja2.UndefinedError):
+            operator.render_template_fields(Context(params={}))
+
+    def 
test_default_filter_idiom_renders_blank_under_strict_undefined_dag_binding(self):
+        with DAG("test_dag", schedule=None) as dag:
+            operator = OpenAIResponseOperator(
+                task_id=TASK_ID,
+                conn_id=CONN_ID,
+                input_text="Write a haiku.",
+                max_output_tokens="{{ params.tokens | default('', true) }}",
+                dag=dag,
+            )
+
+        assert operator.get_template_env().undefined is jinja2.StrictUndefined
+
+        operator.render_template_fields(Context(params={}))
+        assert operator.max_output_tokens == ""
+
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response()
+        operator.hook = mock_hook_instance
+
+        operator.execute(Context())
+
+        call_kwargs = mock_hook_instance.create_response.call_args.kwargs
+        assert "max_output_tokens" not in call_kwargs
+
+    @pytest.mark.parametrize(
+        ("reason", "output_text", "expected_fragment"),
+        [
+            pytest.param(
+                "max_output_tokens",
+                "Truncated hai",
+                "the returned output text is truncated, not empty.",
+                id="max_output_tokens-nonempty-output",
+            ),
+            pytest.param(
+                "content_filter",
+                "",
+                "the returned output text may be empty.",
+                id="content_filter-empty-output",
+            ),
+            pytest.param(
+                # A reasoning model can spend the entire max_output_tokens 
ceiling on reasoning
+                # tokens and produce no visible output text -- the wording 
must be decided by
+                # output_text, not by reason, even when reason is 
"max_output_tokens".
+                "max_output_tokens",
+                "",
+                "the returned output text may be empty.",
+                id="max_output_tokens-empty-output",
+            ),
+        ],
+    )
+    def test_incomplete_details_reason_is_logged(self, caplog, reason, 
output_text, expected_fragment):
+        operator = OpenAIResponseOperator(
+            task_id=TASK_ID, conn_id=CONN_ID, input_text="Write a haiku.", 
max_output_tokens=10
+        )
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response(
+            status="incomplete",
+            incomplete_details=IncompleteDetails(reason=reason),
+            output_text=output_text,
+        )
+        operator.hook = mock_hook_instance
+
+        with caplog.at_level("WARNING"):
+            result = operator.execute(Context())
+
+        assert result == output_text
+        assert any(
+            f"incomplete_details.reason={reason}" in message and 
expected_fragment in message
+            for message in caplog.messages
+        )
+        # The wording is decided by output_text, not by reason: a truthy 
output_text always gets
+        # the "truncated, not empty" message and an empty one always gets "may 
be empty",
+        # regardless of what reason is.
+        if output_text:
+            assert not any("may be empty" in message for message in 
caplog.messages)
+        else:
+            assert not any("truncated, not empty" in message for message in 
caplog.messages)
+
+    def test_incomplete_without_details_uses_truncated_or_empty_message(self, 
caplog):
+        operator = OpenAIResponseOperator(task_id=TASK_ID, conn_id=CONN_ID, 
input_text="Write a haiku.")
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response(
+            status="incomplete", incomplete_details=None, output_text=""
+        )
+        operator.hook = mock_hook_instance
+
+        with caplog.at_level("WARNING"):
+            result = operator.execute(Context())
+
+        assert result == ""
+        assert any("may be truncated or empty" in message for message in 
caplog.messages)
+        assert not any("truncated, not empty" in message for message in 
caplog.messages)
+        assert not any("may be empty" in message for message in 
caplog.messages)
+
+    def 
test_non_completed_without_incomplete_details_keeps_may_be_empty_message(self, 
caplog):
+        operator = OpenAIResponseOperator(task_id=TASK_ID, conn_id=CONN_ID, 
input_text="Write a haiku.")
+        mock_hook_instance = Mock(spec=OpenAIHook)
+        mock_hook_instance.create_response.return_value = 
_build_completed_response(
+            status="failed", output_text=""
+        )
+        operator.hook = mock_hook_instance
+
+        with caplog.at_level("WARNING"):
+            operator.execute(Context())
+
+        assert any(
+            "ended with status failed" in message and "may be empty" in 
message for message in caplog.messages
+        )
+
+
 @pytest.mark.parametrize("wait_for_completion", [True, False])
 def test_openai_trigger_batch_operator_not_deferred(mock_batch, 
wait_for_completion):
     operator = OpenAITriggerBatchOperator(

Reply via email to