kaxil commented on code in PR #72151:
URL: https://github.com/apache/airflow/pull/72151#discussion_r4032445669
##########
providers/openai/tests/unit/openai/operators/test_openai.py:
##########
@@ -94,24 +104,103 @@ def test_openai_response_operator_execute():
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"
+ usage = ResponseUsage(
+ input_tokens=5,
+ input_tokens_details=InputTokensDetails(cached_tokens=1,
cache_write_tokens=0),
+ output_tokens=7,
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=2),
+ total_tokens=12,
)
+ mock_response = Mock(
+ spec=Response, output_text="haiku text", id="resp_123",
status="completed", usage=usage
+ )
+ mock_hook_instance.create_response.return_value = mock_response
operator.hook = mock_hook_instance
- result = operator.execute(Context())
+ context = _build_execute_context()
+ result = operator.execute(context)
+ # Backward compat: the return value is still the aggregated output text,
unchanged
+ # by the new XCom pushes below.
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",
)
+ context["ti"].xcom_push.assert_any_call(key="response_id",
value="resp_123")
+ context["ti"].xcom_push.assert_any_call(
+ key="usage",
+ value={
+ "input_tokens": 5,
+ "input_tokens_details": {"cache_write_tokens": 0, "cached_tokens":
1},
+ "output_tokens": 7,
+ "output_tokens_details": {"reasoning_tokens": 2},
+ "total_tokens": 12,
+ },
+ )
+
+
+def test_openai_response_operator_execute_without_usage():
+ operator = OpenAIResponseOperator(
+ task_id=TASK_ID, conn_id=CONN_ID, input_text="Write a haiku.",
model="test_model"
+ )
+ mock_hook_instance = Mock(spec=OpenAIHook)
+ mock_response = Mock(
+ spec=Response, output_text="haiku text", id="resp_123",
status="completed", usage=None
+ )
+ mock_hook_instance.create_response.return_value = mock_response
+ operator.hook = mock_hook_instance
+
+ context = _build_execute_context()
+ result = operator.execute(context)
+
+ assert result == "haiku text"
+ context["ti"].xcom_push.assert_any_call(key="usage", value=None)
+
+
+def test_openai_response_operator_execute_skips_xcom_push_when_disabled():
+ operator = OpenAIResponseOperator(
+ task_id=TASK_ID,
+ conn_id=CONN_ID,
+ input_text="Write a haiku.",
+ model="test_model",
+ do_xcom_push=False,
+ )
+ mock_hook_instance = Mock(spec=OpenAIHook)
+ mock_response = Mock(spec=Response, output_text="haiku text",
id="resp_123", status="completed")
+ mock_hook_instance.create_response.return_value = mock_response
+ operator.hook = mock_hook_instance
+
+ context = _build_execute_context()
+ result = operator.execute(context)
+
+ assert result == "haiku text"
+ context["ti"].xcom_push.assert_not_called()
+
+
+def test_openai_response_operator_templates_input_text_and_response_kwargs():
Review Comment:
This covers one shape -- a flat one-key dict whose value is a plain `{{
params.x }}` string -- but the changelog warns about a different one: "a
defined variable is substituted silently, sending a different prompt or tool
schema to the paid API than before". A tool schema is a list of dicts, and the
templater recurses into lists, dicts and tuples and rewrites every string it
finds, so that is the shape that bites. Nothing here binds it, nor the `{% raw
%}` escape the docs promise, nor a non-string value surviving untouched --
narrow the field to top-level strings tomorrow and every assertion in this test
still passes. To satisfy this, parametrize over the three: `{"instructions":
"{% raw %}{{ not_a_variable }}{% endraw %}"}` -> unchanged literal; `{"tools":
[{"type": "function", "parameters": {"k": "{{ params.input_text }}"}}],
"max_retries": 3}` -> nested string rendered and the int untouched; plus the
existing case.
##########
providers/openai/src/airflow/providers/openai/operators/openai.py:
##########
@@ -134,9 +137,23 @@ class OpenAIResponseOperator(BaseOperator):
:ref:`howto/operator:OpenAIResponseOperator`
For possible options, see:
https://platform.openai.com/docs/api-reference/responses/create
+
+ ``execute`` also pushes two XCom keys: ``response_id`` (the response's ID,
usable as
+ a downstream call's ``previous_response_id``) and ``usage`` (the
response's token
+ usage, or ``None`` when the API omits it). ``usage`` is the nested dict
returned by
+ ``ResponseUsage.model_dump()``: top-level ``input_tokens``,
``output_tokens`` and
+ ``total_tokens`` counts, plus the nested ``input_tokens_details`` and
+ ``output_tokens_details`` dicts. ``input_tokens_details.cached_tokens`` is
part of
+ the ``input_tokens`` total, not additional to it, so pricing a run
correctly means
+ reading the breakdown rather than treating ``input_tokens`` as a single
uniformly
+ priced count -- see OpenAI's `prompt caching guide
+ <https://platform.openai.com/docs/guides/prompt-caching>`_ for how cached
tokens are
+ priced. Beyond that, ``usage`` reports token counts only -- the OpenAI
response
+ carries no cost field, so turning any of these counts into a price means
multiplying
+ by your own per-token rate.
"""
- template_fields: Sequence[str] = ("input_text", "max_output_tokens",
"max_tool_calls")
+ template_fields: Sequence[str] = ("input_text", "response_kwargs",
"max_output_tokens", "max_tool_calls")
Review Comment:
The changelog note scopes the hazard to a literal `{{ ... }}` string, but
templating this dict changes value *types* too, and in one direction it breaks
Dags that contain no Jinja at all. Under `render_template_as_native_obj=True`,
`native_concat` runs `ast.literal_eval` on every string value, so an existing
`response_kwargs={"metadata": {"run": "20240115"}}` starts sending `{"run":
20240115}` after upgrade -- and OpenAI's `metadata` is a string-to-string map.
Under the default environment the reverse: `{"max_output_tokens": "{{
params.tokens }}"}` renders to `"500"` and goes to the API as a string, because
`_build_response_kwargs` only coerces names in `self._supplied_ceilings` (set
from the operator arguments in `__init__`), so a ceiling living only in
`response_kwargs` now renders but skips `_coerce_token_ceiling` entirely. To
satisfy this: extend the note and `:param response_kwargs:` to cover both type
effects, and either run `_coerce_token_ceiling` over any `_TOKEN_CEILING_P
ARAM_NAMES` key found in the rendered dict, or say plainly that a ceiling
placed there is neither coerced nor validated.
##########
providers/openai/src/airflow/providers/openai/operators/openai.py:
##########
@@ -134,9 +137,23 @@ class OpenAIResponseOperator(BaseOperator):
:ref:`howto/operator:OpenAIResponseOperator`
For possible options, see:
https://platform.openai.com/docs/api-reference/responses/create
+
+ ``execute`` also pushes two XCom keys: ``response_id`` (the response's ID,
usable as
Review Comment:
These thirteen lines are near-verbatim with
`docs/operators/openai.rst:47-59` -- an 86-word identical run -- and the two
copies have already drifted before merge: the rst opens with "When
`do_xcom_push` is enabled (the default)" and closes with "Setting
`do_xcom_push=False` skips both pushes", and the docstring says neither. So the
class doc currently promises two XCom pushes with no hint that the operator's
own flag suppresses them, which is the one fact that makes the keys safe. The
convention is a cross-reference, not a copy -- `BashOperator` shares 182
characters with its howto and `PythonOperator` 37, just the `:ref:`. To satisfy
this: cut this block to the mechanical facts (the two keys, that `usage` is
`ResponseUsage.model_dump()` and `None` when omitted, that `do_xcom_push=False`
skips them) and let the `.. seealso::` already at :135-137 carry the pricing
discussion.
##########
providers/openai/tests/system/openai/example_openai.py:
##########
@@ -101,13 +101,26 @@ def task_to_store_input_text_in_xcom():
# [END howto_operator_openai_embedding]
# [START howto_operator_openai_response]
- OpenAIResponseOperator(
+ 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."},
)
+ # Chains onto the previous response via its response_id XCom, continuing
+ # the same conversation without resending prior turns.
+ openai_response_follow_up = OpenAIResponseOperator(
Review Comment:
A Jinja `ti.xcom_pull` string creates no task dependency, so the `>>` at
line 122 is load-bearing -- without it these two race and the follow-up renders
`previous_response_id` to the literal string `"None"`, which reaches the paid
API and 400s. The comment above explains what the chaining does but not that,
so someone copying this snippet can reasonably delete the `>>`. `XComArg` fixes
both halves: `response_kwargs={"previous_response_id": XComArg(openai_response,
key="response_id")}` resolves through the same dict recursion, creates the edge
automatically so line 122 can go, and raises `XComNotFound` instead of
rendering `"None"` when the key is absent. It works on the 2.11 floor too. To
satisfy this: either switch to `XComArg`, or keep the string and extend the
comment to say the Jinja pull creates no dependency so the edge is declared
explicitly below.
##########
providers/openai/docs/operators/openai.rst:
##########
@@ -44,14 +44,30 @@ 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.
+tool use. The operator returns the response's aggregated output text. When
``do_xcom_push`` is
Review Comment:
This paragraph announces the chaining, but the `store` bullet further down
the same page (lines 118-122, outside this diff) still says `execute` "only
passes `response.id` to the task log" and that "nothing downstream of this
operator's task can retrieve a stored response's id" -- the exact limitation
this PR removes. A reader who scrolls to the options list is told the feature
does not exist. To satisfy this: rewrite that bullet to say the id is pushed as
`response_id` when `do_xcom_push` is enabled, and add the caveat that now
becomes real -- `store=False` makes the pushed id useless as a
`previous_response_id`. Separately, line 59's "skips both pushes" is
incomplete: `do_xcom_push=False` also suppresses the task's own `return_value`
XCom, so `openai_response.output` stops resolving.
##########
providers/openai/docs/changelog.rst:
##########
@@ -20,6 +20,25 @@
Changelog
---------
+.. Behavior note
Review Comment:
`.. Behavior note` has no `::`, so docutils parses it as an RST comment
rather than a directive -- the rendered page emits `<!-- Behavior note -->` and
lines 25-40 become three unlabelled body paragraphs with no admonition styling.
No docutils warning is emitted for this form, so no Sphinx build will ever
catch it. The placement is right and I am not asking you to move it; the
sibling note at :45 shows the form. To satisfy this: `.. note::` with the body
indented four spaces, and open it with the version that ships this change,
since as written it sits directly above the released 1.8.2 heading and reads as
applying to that. While editing, the hazard list is `{{ }}`-only: `{# ... #}`
is silently deleted from the string and `{% ... %}` raises
`TemplateSyntaxError` -- `{% raw %}` already covers all three.
##########
providers/openai/src/airflow/providers/openai/operators/openai.py:
##########
@@ -272,6 +289,13 @@ def execute(self, context: Context) -> str:
response.status,
)
self.log.info("Generated response %s", response.id)
+ if self.do_xcom_push:
Review Comment:
XComs are cleared at the start of every attempt, so this key only ever holds
the final attempt's counts. Every earlier attempt of a retried task already
made a billed `create_response` call whose tokens OpenAI charges but which this
value no longer shows, so a user summing `usage.total_tokens` to price a Dag
run gets a number that is low exactly on the runs that cost the most -- and the
PR sells these counts as the run's cost signal. The sibling provider already
solved this: `providers/anthropic/.../operators/agent.py:284-293` pushes the
same `usage` key and stamps `try_number` with a comment saying it is precisely
to avoid silently under-reporting spend across retries. To satisfy this: stamp
`try_number` into the dict the same way, or add one sentence to the docstring
saying `usage` reflects the final attempt only.
##########
providers/openai/tests/unit/openai/operators/test_openai.py:
##########
@@ -94,24 +104,103 @@ def test_openai_response_operator_execute():
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"
+ usage = ResponseUsage(
+ input_tokens=5,
+ input_tokens_details=InputTokensDetails(cached_tokens=1,
cache_write_tokens=0),
+ output_tokens=7,
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=2),
+ total_tokens=12,
)
+ mock_response = Mock(
+ spec=Response, output_text="haiku text", id="resp_123",
status="completed", usage=usage
+ )
+ mock_hook_instance.create_response.return_value = mock_response
operator.hook = mock_hook_instance
- result = operator.execute(Context())
+ context = _build_execute_context()
+ result = operator.execute(context)
+ # Backward compat: the return value is still the aggregated output text,
unchanged
+ # by the new XCom pushes below.
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",
)
+ context["ti"].xcom_push.assert_any_call(key="response_id",
value="resp_123")
+ context["ti"].xcom_push.assert_any_call(
+ key="usage",
+ value={
+ "input_tokens": 5,
+ "input_tokens_details": {"cache_write_tokens": 0, "cached_tokens":
1},
+ "output_tokens": 7,
+ "output_tokens_details": {"reasoning_tokens": 2},
+ "total_tokens": 12,
+ },
+ )
+
+
+def test_openai_response_operator_execute_without_usage():
+ operator = OpenAIResponseOperator(
+ task_id=TASK_ID, conn_id=CONN_ID, input_text="Write a haiku.",
model="test_model"
+ )
+ mock_hook_instance = Mock(spec=OpenAIHook)
+ mock_response = Mock(
+ spec=Response, output_text="haiku text", id="resp_123",
status="completed", usage=None
+ )
+ mock_hook_instance.create_response.return_value = mock_response
+ operator.hook = mock_hook_instance
+
+ context = _build_execute_context()
+ result = operator.execute(context)
+
+ assert result == "haiku text"
+ context["ti"].xcom_push.assert_any_call(key="usage", value=None)
+
+
+def test_openai_response_operator_execute_skips_xcom_push_when_disabled():
Review Comment:
This test passes unchanged against pre-PR code -- `execute` pushed nothing
there either -- so on its own it binds none of this PR; it is a forward guard
on the `if self.do_xcom_push:` gate. It cannot report that cleanly though: the
mock on line 172 omits `usage`, so if the gate were deleted `execute` would hit
`response.usage` on a `Mock(spec=Response)` and raise `AttributeError` before
`assert_not_called()` runs, and pytest would show the wrong failure. To satisfy
this: parametrize the enabled and disabled cases over one body asserting
`xcom_push.call_count == expected`, and build the response with the existing
`_build_completed_response()` helper so a removed gate fails on the assertion.
That also closes a gap above -- the enabled test uses `assert_any_call` twice
and never pins the count, so it still passes if a third key were pushed.
--
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]