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 d318d5484f3 Fail LLMOperator approval at parse time on Airflow cores
older than 3.1 (#73261)
d318d5484f3 is described below
commit d318d5484f3db618b84534854ce658ab034405b2
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Sep 17 14:04:26 2026 +0100
Fail LLMOperator approval at parse time on Airflow cores older than 3.1
(#73261)
Human-in-the-loop review needs Airflow 3.1+, but require_approval=True had
no
version guard. On a 3.0.x core, which apache-airflow>=3.0.0 supports, the
Dag
imported fine and the task ran the model call before defer_for_approval
failed
on an import that cannot succeed there. Every retry paid for the call again.
Reject the parameter in __init__ instead, matching how AgentOperator already
gates enable_hitl_review.
---
providers/common/ai/docs/operators/llm.rst | 8 +++-
.../airflow/providers/common/ai/operators/llm.py | 10 ++++-
.../ai/tests/unit/common/ai/decorators/test_llm.py | 3 ++
.../unit/common/ai/decorators/test_llm_branch.py | 3 ++
.../ai/decorators/test_llm_schema_compare.py | 3 ++
.../unit/common/ai/decorators/test_llm_sql.py | 3 ++
.../ai/tests/unit/common/ai/operators/test_llm.py | 52 +++++++++++++++++++++-
7 files changed, 78 insertions(+), 4 deletions(-)
diff --git a/providers/common/ai/docs/operators/llm.rst
b/providers/common/ai/docs/operators/llm.rst
index ecbbfe3584a..ce0b2e18784 100644
--- a/providers/common/ai/docs/operators/llm.rst
+++ b/providers/common/ai/docs/operators/llm.rst
@@ -240,6 +240,12 @@ HITL interface. Optionally allow the reviewer to edit the
output before
approving with ``allow_modifications=True``, and set a deadline with
``approval_timeout``.
+Human-in-the-loop review needs Airflow 3.1+. On an older core the operator
raises
+``AirflowOptionalProviderFeatureException`` when it is constructed, so the Dag
file
+fails to import, and with it every Dag defined in that file. A dynamically
mapped
+task (``.expand()``) is only constructed when it runs, so there the same error
+surfaces as a task failure -- still before the model is called.
+
When ``approval_timeout`` expires without a review, the task fails by default.
Set ``on_approval_timeout="approve"`` to return the generated output instead,
so
an unattended pipeline keeps moving. ``"reject"`` answers the review with a
@@ -287,7 +293,7 @@ Parameters
Fails the task when token / request / tool-call budgets are exceeded, or
when a
templated dict value cannot be coerced. Default ``None``.
- ``require_approval``: If ``True``, the task defers after generating output
and waits
- for human review. Default ``False``.
+ for human review. Default ``False``. Needs Airflow 3.1+.
- ``approval_timeout``: Maximum time to wait for a review (``timedelta``).
``None``
means wait indefinitely. Default ``None``.
- ``on_approval_timeout``: Outcome when ``approval_timeout`` expires without a
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
index 358775b221d..fb813b7744f 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
@@ -31,7 +31,8 @@ from airflow.providers.common.ai.utils.logging import
log_run_summary
from airflow.providers.common.ai.utils.output_type import
rehydrate_pydantic_output
from airflow.providers.common.ai.utils.usage import coerce_usage_limits
from airflow.providers.common.compat.notifier import BaseNotifier
-from airflow.providers.common.compat.sdk import BaseOperator
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, BaseOperator
+from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_1_PLUS
try:
# New enough cores register an operator's declared ``output_type`` classes
for
@@ -99,7 +100,7 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
caveats.
:param require_approval: If ``True``, the task defers after generating
output and waits for a human reviewer to approve or reject via the
- HITL interface. Default ``False``.
+ HITL interface. Default ``False``. Needs Airflow 3.1+.
:param approval_timeout: Maximum time to wait for a review. When
exceeded, ``on_approval_timeout`` decides the outcome.
:param on_approval_timeout: What to do when ``approval_timeout`` expires
@@ -172,6 +173,11 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
raise ValueError(
f"on_approval_timeout must be 'fail', 'approve', or 'reject',
got {on_approval_timeout!r}."
)
+ # Checked before the combination rule so an old core reports the core
version
+ # rather than sending the user to drop an argument that was never the
problem.
+ if require_approval and not AIRFLOW_V_3_1_PLUS:
+ raise
AirflowOptionalProviderFeatureException("require_approval=True needs Airflow
3.1+.")
+
if on_approval_timeout != "fail" and not (
require_approval and approval_timeout is not None and
approval_timeout > timedelta(0)
):
diff --git a/providers/common/ai/tests/unit/common/ai/decorators/test_llm.py
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm.py
index 480738617dd..e68d6ce6a78 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_llm.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_llm.py
@@ -23,6 +23,8 @@ from pydantic_ai.messages import ImageUrl
from airflow.providers.common.ai.decorators.llm import _LLMDecoratedOperator
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+
class TestLLMDecoratedOperator:
def test_custom_operator_name(self):
@@ -79,6 +81,7 @@ class TestLLMDecoratedOperator:
assert op.prompt == prompt
mock_agent.run_sync.assert_called_once_with(prompt, usage_limits=None)
+ @pytest.mark.skipif(not AIRFLOW_V_3_1_PLUS, reason="require_approval needs
Airflow >= 3.1.0")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
def
test_sequence_prompt_with_require_approval_raises_before_run_sync(self,
mock_hook_cls):
"""Sequence prompt + require_approval=True fails before the agent
runs."""
diff --git
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
index c832661bee4..3ff69936261 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
@@ -25,6 +25,8 @@ from pydantic_ai.messages import ImageUrl
from airflow.providers.common.ai.decorators.llm_branch import
_LLMBranchDecoratedOperator
from airflow.providers.common.ai.operators.llm_branch import LLMBranchOperator
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+
class TestLLMBranchDecoratedOperator:
def test_custom_operator_name(self):
@@ -73,6 +75,7 @@ class TestLLMBranchDecoratedOperator:
with pytest.raises(TypeError, match="must be"):
op.execute(context={})
+ @pytest.mark.skipif(not AIRFLOW_V_3_1_PLUS, reason="require_approval needs
Airflow >= 3.1.0")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
def
test_sequence_prompt_with_require_approval_raises_before_run_sync(self,
mock_hook_cls):
"""Sequence prompt + require_approval=True fails before the agent
runs."""
diff --git
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
index 87ca8a10a0d..b56e4eed118 100644
---
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
+++
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
@@ -27,6 +27,8 @@ from airflow.providers.common.ai.operators.llm_schema_compare
import (
SchemaCompareResult,
)
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+
def _make_compare_result():
return SchemaCompareResult(
@@ -114,6 +116,7 @@ class TestLLMSchemaCompareDecoratedOperator:
forwarded_prompt = mock_agent.run_sync.call_args[0][0]
assert forwarded_prompt == prompt
+ @pytest.mark.skipif(not AIRFLOW_V_3_1_PLUS, reason="require_approval needs
Airflow >= 3.1.0")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
@patch.object(LLMSchemaCompareOperator, "_build_schema_context",
return_value="mocked schema")
def test_sequence_prompt_with_require_approval_raises_before_run_sync(
diff --git
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
index 16b85f094a7..099b4118c78 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
@@ -23,6 +23,8 @@ from pydantic_ai.messages import ImageUrl
from airflow.providers.common.ai.decorators.llm_sql import
_LLMSQLDecoratedOperator
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+
class TestLLMSQLDecoratedOperator:
def test_custom_operator_name(self):
@@ -79,6 +81,7 @@ class TestLLMSQLDecoratedOperator:
assert op.prompt == prompt
mock_agent.run_sync.assert_called_once_with(prompt, usage_limits=None)
+ @pytest.mark.skipif(not AIRFLOW_V_3_1_PLUS, reason="require_approval needs
Airflow >= 3.1.0")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
def
test_sequence_prompt_with_require_approval_raises_before_run_sync(self,
mock_hook_cls):
"""Sequence prompt + require_approval=True fails before the agent
runs."""
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
index 640e96640f4..3bed2f0f16f 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
@@ -42,7 +42,7 @@ except ImportError:
_CORE_WALKER = False
from airflow.providers.common.compat.notifier import BaseNotifier
-from airflow.providers.common.compat.sdk import TaskDeferred
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, TaskDeferred
if AIRFLOW_V_3_3_PLUS:
# On 3.3+ cores require_approval pauses the task in AWAITING_INPUT; older
cores defer
@@ -313,6 +313,56 @@ def _make_context(ti_id=None):
return MagicMock(**{"__getitem__": lambda self, key: {"task_instance":
ti}[key]})
+class TestLLMOperatorApprovalVersionGate:
+ """__init__ rejects require_approval on cores without human-in-the-loop
support.
+
+ Deliberately carries no class-level 3.1 skipif. These tests simulate an
old core by
+ patching the flag, so they must not inherit the sibling class's skip --
and on a
+ genuine pre-3.1 core, such as the 3.0.6 providers-compatibility job, they
are the
+ only tests that exercise the gate natively.
+ """
+
+ @pytest.mark.parametrize(
+ ("kwargs", "expected_exception", "match"),
+ [
+ pytest.param(
+ {"require_approval": True},
+ AirflowOptionalProviderFeatureException,
+ "Airflow 3.1",
+ id="require-approval-rejected",
+ ),
+ pytest.param(
+ {"require_approval": True, "on_approval_timeout": "approve"},
+ AirflowOptionalProviderFeatureException,
+ "Airflow 3.1",
+ id="version-beats-combination-rule",
+ ),
+ pytest.param(
+ {"require_approval": True, "on_approval_timeout": "nope"},
+ ValueError,
+ "on_approval_timeout must be",
+ id="literal-check-keeps-precedence",
+ ),
+ ],
+ )
+ @patch("airflow.providers.common.ai.operators.llm.AIRFLOW_V_3_1_PLUS",
False)
+ def test_old_core_reports_the_blocking_argument(self, kwargs,
expected_exception, match):
+ """Which of two applicable errors __init__ reports, and in which order.
+
+ Dropping on_approval_timeout would not make the operator work on an
older core,
+ so the version has to beat the combination rule. A bad literal is
wrong on every
+ core, so it keeps its own precise message -- which also pins the guard
below the
+ literal check, since hoisting it would swap that message for the
version one.
+ """
+ with pytest.raises(expected_exception, match=match):
+ LLMOperator(task_id="t", prompt="p", llm_conn_id="c", **kwargs)
+
+ @patch("airflow.providers.common.ai.operators.llm.AIRFLOW_V_3_1_PLUS",
False)
+ def test_operator_without_approval_builds_on_old_core(self):
+ op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c")
+ assert op.require_approval is False
+
+
@pytest.mark.skipif(
not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with
Airflow >= 3.1.0"
)