This is an automated email from the ASF dual-hosted git repository.
guan404ming 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 69509289c96 Preserve output_type through human approval in LLM
operators (#70075)
69509289c96 is described below
commit 69509289c9682c6efec578731b52f810402bd1d9
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Sun Jul 19 21:51:31 2026 +0800
Preserve output_type through human approval in LLM operators (#70075)
* Preserve output_type through human approval in LLM operators
* Cover BaseModel in output_type restore test
---
.../airflow/providers/common/ai/mixins/approval.py | 8 ++++----
.../airflow/providers/common/ai/utils/output_type.py | 20 ++++++++++----------
.../ai/tests/unit/common/ai/mixins/test_approval.py | 13 +++++++++----
.../ai/tests/unit/common/ai/operators/test_llm.py | 19 +++++++++++++++++++
.../tests/unit/common/ai/utils/test_output_type.py | 15 ++++++++++++++-
5 files changed, 56 insertions(+), 19 deletions(-)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
index 5ebd679efcd..9f045c02548 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
@@ -21,7 +21,7 @@ import logging
from datetime import timedelta
from typing import TYPE_CHECKING, Any, Protocol
-from pydantic import BaseModel
+from pydantic import BaseModel, TypeAdapter
from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_3_PLUS
@@ -107,9 +107,9 @@ class LLMApprovalMixin:
if isinstance(output, BaseModel):
output = output.model_dump_json()
- if not isinstance(output, str):
- # Always make string output so that when comparing in the
execute_complete matches
- output = str(output)
+ elif not isinstance(output, str):
+ # JSON round-trip: execute_complete validates the string back into
output_type.
+ output = TypeAdapter(type(output)).dump_json(output).decode()
ti_id = context["task_instance"].id
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
index 2ae9b6a5bc3..44ca8505b54 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from typing import Any
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel, TypeAdapter, ValidationError
def rehydrate_pydantic_output(
@@ -30,25 +30,25 @@ def rehydrate_pydantic_output(
serialize_output: bool,
) -> Any:
"""
- Turn a JSON string back into the ``output_type`` Pydantic model.
+ Turn a JSON string back into a value of ``output_type``.
Used by the HITL/approval paths in ``LLMOperator`` and ``AgentOperator``
- that round-trip the model through a string when deferring to a human
- reviewer. When ``output_type`` is not a ``BaseModel`` subclass, returns
- ``raw`` unchanged so the caller can apply its own fallback (e.g.
- ``json.loads``). When validation fails (reviewer edited the string into
- something the schema rejects), also returns ``raw`` unchanged.
+ that round-trip the output through a string when deferring to a human
+ reviewer. ``str`` outputs pass through unchanged; any other ``output_type``
+ (``BaseModel`` subclass, ``int``, ``list[str]``, ...) is validated with a
+ pydantic ``TypeAdapter``. When validation fails (reviewer edited the string
+ into something the type rejects), returns ``raw`` unchanged.
When ``serialize_output`` is ``True``, returns the model dumped to a
``dict`` -- matches the operator's ``serialize_output=True`` opt-in for
consumers that want the dict shape.
"""
- if not (isinstance(output_type, type) and issubclass(output_type,
BaseModel)):
+ if output_type is str:
return raw
try:
- rehydrated = output_type.model_validate_json(raw)
+ rehydrated = TypeAdapter(output_type).validate_json(raw)
except (ValidationError, ValueError, TypeError):
return raw
- if serialize_output:
+ if serialize_output and isinstance(rehydrated, BaseModel):
return rehydrated.model_dump()
return rehydrated
diff --git a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
index 54b675da723..46120b3c3c3 100644
--- a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
+++ b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
@@ -118,15 +118,20 @@ class TestDeferForApproval:
defer_kwargs = approval_op.defer.call_args[1]
assert defer_kwargs["kwargs"]["generated_output"] ==
'{"text":"Paris","confidence":0.95}'
+ @pytest.mark.parametrize(
+ ("output", "expected"),
+ [(42, "42"), (True, "true"), ([1, "a"], '[1,"a"]'), ({"k": "v"},
'{"k":"v"}')],
+ ids=["int", "bool", "list", "dict"],
+ )
@patch(HITL_TRIGGER_PATH, autospec=True)
@patch(UPSERT_HITL_PATH)
- def test_non_string_non_pydantic_output_is_stringified(
- self, mock_upsert, mock_trigger_cls, approval_op, context
+ def test_non_string_non_pydantic_output_is_json_encoded(
+ self, mock_upsert, mock_trigger_cls, approval_op, context, output,
expected
):
- approval_op.defer_for_approval(context, 42)
+ approval_op.defer_for_approval(context, output)
defer_kwargs = approval_op.defer.call_args[1]
- assert defer_kwargs["kwargs"]["generated_output"] == "42"
+ assert defer_kwargs["kwargs"]["generated_output"] == expected
@patch(HITL_TRIGGER_PATH, autospec=True)
@patch(UPSERT_HITL_PATH)
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 f9f3bf09099..e2004b4031c 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
@@ -379,6 +379,25 @@ class TestLLMOperatorApproval:
assert isinstance(result, Summary)
assert result.text == "hello"
+ @pytest.mark.parametrize(
+ ("output_type", "generated_output", "expected"),
+ [
+ (int, "5", 5),
+ (list[str], '["a","b"]', ["a", "b"]),
+ pytest.param(Summary, '{"text":"hello"}', Summary(text="hello"),
marks=requires_typed_xcom),
+ ],
+ ids=["int", "list", "basemodel"],
+ )
+ def test_execute_complete_restores_non_str_output_type(self, output_type,
generated_output, expected):
+ op = LLMOperator(
+ task_id="t", prompt="p", llm_conn_id="c", output_type=output_type,
require_approval=True
+ )
+ event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+
+ result = op.execute_complete({}, generated_output=generated_output,
event=event)
+
+ assert result == expected
+
@pytest.mark.skipif(
not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with
Airflow >= 3.1.0"
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
b/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
index 4c3dcae5ee2..f7e1a4799e2 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import pytest
from pydantic import BaseModel
from airflow.providers.common.ai.utils.output_type import
rehydrate_pydantic_output
@@ -35,10 +36,22 @@ class TestRehydratePydanticOutput:
result = rehydrate_pydantic_output(A, '{"x": 7}',
serialize_output=True)
assert result == {"x": 7}
- def test_returns_raw_for_non_basemodel(self):
+ def test_returns_raw_for_str_output_type(self):
result = rehydrate_pydantic_output(str, "anything",
serialize_output=False)
assert result == "anything"
+ @pytest.mark.parametrize(
+ ("output_type", "raw", "expected"),
+ [(int, "5", 5), (bool, "true", True), (list[str], '["a", "b"]', ["a",
"b"])],
+ ids=["int", "bool", "list"],
+ )
+ def test_validates_other_types_with_type_adapter(self, output_type, raw,
expected):
+ assert rehydrate_pydantic_output(output_type, raw,
serialize_output=False) == expected
+
+ def test_returns_raw_when_type_adapter_rejects(self):
+ result = rehydrate_pydantic_output(int, "not-a-number",
serialize_output=False)
+ assert result == "not-a-number"
+
def test_returns_raw_on_invalid_json(self):
result = rehydrate_pydantic_output(A, "not-json",
serialize_output=False)
assert result == "not-json"