kaxil commented on code in PR #72150:
URL: https://github.com/apache/airflow/pull/72150#discussion_r3993071204


##########
providers/openai/src/airflow/providers/openai/operators/openai.py:
##########
@@ -101,30 +123,117 @@ 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,
         conn_id: str,
         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)
         self.conn_id = conn_id
         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._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 cannot possibly still be an 
unrendered template.
+
+        A ``str`` value might be a template awaiting 
``render_template_fields()``, so it must wait
+        for ``_build_response_kwargs()`` at ``execute()`` time. Any other type 
(``int``, ``bool``,
+        ``float``) is already final at construction -- it only reaches here as 
a literal, never via
+        Jinja rendering -- so an invalid one is rejected at Dag-parse time 
instead of surfacing only
+        when the task runs.
+        """
+        for param_name in self._TOKEN_CEILING_PARAM_NAMES:
+            value = getattr(self, param_name)
+            if value is not None and not isinstance(value, str):

Review Comment:
   `not isinstance(value, str)` reads "final literal", but an `XComArg` is 
neither a str nor final: `max_output_tokens=budget()` now raises `ValueError: 
'max_output_tokens' must be an integer, got 
XComArg(<Task(_PythonDecoratedOperator): budget>).` at import time, taking the 
whole Dag file with it, even though `render_template` resolves anything 
exposing `.resolve(context)` (`templater.py:264-265`) so an XComArg is a 
supported value for a template field. The `(bool, float)` denylist in 
`_coerce_token_ceiling` has the mirror hole: `Decimal('10.5')` and 
`Fraction(21, 2)` both slip past it and reach the wire as `10`, which is 
exactly the silent truncation the comment there gives as the reason float is 
rejected. An allowlist on both sides closes both: fire the eager check only for 
`isinstance(value, (bool, float, int))`, and have `_coerce_token_ceiling` 
reject anything that is not `int`-or-`str` with `bool` still excluded.



##########
providers/openai/docs/operators/openai.rst:
##########
@@ -53,6 +53,22 @@ 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"`` and 
truncated
+``output_text``, so ``return_value`` will be the truncated text, not an error.
+
+A rendered ``max_output_tokens`` or ``max_tool_calls`` that is blank or 
whitespace-only -- for
+example ``max_output_tokens="{{ params.tokens or '' }}"`` when 
``params.tokens`` is unset -- is

Review Comment:
   This `{{ params.tokens or '' }}` spelling is mine from the last round and it 
does not survive `StrictUndefined`, which is the Dag default (`dag.py:472`): 
with `tokens` absent from `params` it raises `UndefinedError: 'dict object' has 
no attribute 'tokens'`, and it renders `''` only when `tokens` is a declared 
param whose default is `None` -- not the "unset" this sentence describes. `{{ 
params.tokens | default('', true) }}` renders `''` in both cases, and `| 
default('')` alone is no good since it yields `'None'`, which the docstring 
says raises. Worth carrying the working spelling into the `exampleinclude` 
target as well, since `example_openai.py` still shows only the 
`response_kwargs` dict that these 16 new lines of prose steer users away from.



##########
providers/openai/docs/operators/openai.rst:
##########
@@ -53,6 +53,22 @@ 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

Review Comment:
   This paragraph introduces the two ceilings as a matched pair, then explains 
the observable consequence for only one of them. Nothing in the new warning 
branches can ever fire for `max_tool_calls`: `IncompleteDetails.reason` is 
`Literal['max_output_tokens', 'content_filter']` at the 2.37.0 floor, and the 
SDK documents the cap as silent -- "Any further attempts to call a tool by the 
model will be ignored" -- with no status change and no `incomplete_details`. So 
a run that dropped tool calls partway through looks identical to a clean one, 
in the logs and in `return_value` alike. One sentence saying so here would save 
someone a debugging session.



##########
providers/openai/src/airflow/providers/openai/operators/openai.py:
##########
@@ -101,30 +123,117 @@ 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,
         conn_id: str,
         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)
         self.conn_id = conn_id
         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._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 cannot possibly still be an 
unrendered template.
+
+        A ``str`` value might be a template awaiting 
``render_template_fields()``, so it must wait
+        for ``_build_response_kwargs()`` at ``execute()`` time. Any other type 
(``int``, ``bool``,
+        ``float``) is already final at construction -- it only reaches here as 
a literal, never via
+        Jinja rendering -- so an invalid one is rejected at Dag-parse time 
instead of surfacing only
+        when the task runs.
+        """
+        for param_name in self._TOKEN_CEILING_PARAM_NAMES:
+            value = getattr(self, param_name)
+            if value is not None and not isinstance(value, str):
+                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 | 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
+        # int check below; float must also be rejected explicitly since 
int(10.5) == 10 silently
+        # truncates instead of raising -- both can reach here as real Python 
objects, not just
+        # strings, when a Dag uses render_template_as_native_obj=True.
+        if isinstance(value, (bool, float)):
+            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)
+            # Blank means unset; the response_kwargs conflict was already 
rejected in __init__.
+            if value is None or (isinstance(value, str) and value.strip() == 
""):
+                continue
+            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 == "max_output_tokens":
+                self.log.warning(
+                    "Response %s is incomplete (incomplete_details.reason=%s); 
the returned output "
+                    "text is truncated, not empty.",

Review Comment:
   Following up on my comment on this line from the last round: the split 
landed as asked, but we both missed a case. openai 2.37.0 documents 
`max_output_tokens` as bounding "visible output tokens **and** reasoning 
tokens", so a reasoning model can spend the whole ceiling on reasoning and 
return no message item at all. Built a real pydantic-validated `Response` with 
`status="incomplete"`, `reason="max_output_tokens"` and a single 
`ResponseReasoningItem`: `output_text` is `''`, `execute()` returns `''`, and 
this warning still says the text is "truncated, not empty". Since `model` is a 
plain operator arg, `model="gpt-5"` plus a modest ceiling is ordinary config. 
Branching on `response.output_text`, which you already have in hand, would be 
honest in every case, and the guide's stronger promise that `return_value` 
"will be the truncated text, not an error" needs the same softening.



-- 
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]

Reply via email to