This is an automated email from the ASF dual-hosted git repository.
eladkal 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 77b21f34c05 Fix Common AI durable retries and tool-approval resumes on
pydantic-ai 2.50+ (#74313)
77b21f34c05 is described below
commit 77b21f34c05f7a8b17c43827327c912605d144cd
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Oct 6 08:08:56 2026 +0100
Fix Common AI durable retries and tool-approval resumes on pydantic-ai
2.50+ (#74313)
pydantic-ai 2.50 added a float field to RunUsage, audio_seconds. The task
state store loader required every count field to be an int, so the usage
it had saved on the first attempt (audio_seconds=0.0) failed to load on the
retry or resume with "is not an int (got 0.0)". Float fields now load as
floats.
---
.../providers/common/ai/utils/usage_budget.py | 12 ++++++-
.../unit/common/ai/utils/test_usage_budget.py | 42 ++++++++++++++++++++++
2 files changed, 53 insertions(+), 1 deletion(-)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/usage_budget.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/usage_budget.py
index cb8c43e412e..f49fde96385 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/usage_budget.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/usage_budget.py
@@ -72,7 +72,8 @@ def load_run_usage(raw: Any, *, key: str) -> RunUsage:
module still loads. Only the fields ``RunUsage`` currently declares are
read.
:raises ValueError: *raw* is not a dict, or a field has the wrong shape
(``cost``
- not a valid number, a count field not an int, ``details`` not a dict).
The
+ not a valid number, a count field not an int, a float field such as
+ ``audio_seconds`` not a number, ``details`` not a dict). The
message names *key* so the error points at which task state store key
to
delete to reset the budget.
"""
@@ -107,6 +108,15 @@ def load_run_usage(raw: Any, *, key: str) -> RunUsage:
# loaded RunUsage silently mutate the raw dict this was read from
(matters
# most for copy_run_usage's dump/load round trip of a live
RunUsage).
kwargs["details"] = dict(value)
+ elif field.type in (float, "float"):
+ # A measure rather than a count, such as ``audio_seconds``
(pydantic-ai 2.50+).
+ # usage.py uses postponed annotations, so ``field.type`` is the
string "float".
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
+ raise ValueError(
+ f"{key!r}[{field.name!r}] in the task state store is not a
number (got {value!r}); "
+ "delete the key to reset."
+ )
+ kwargs[field.name] = float(value)
else:
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(
diff --git
a/providers/common/ai/tests/unit/common/ai/utils/test_usage_budget.py
b/providers/common/ai/tests/unit/common/ai/utils/test_usage_budget.py
index c95a2ddd4fa..9b6ee6e850a 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_usage_budget.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_usage_budget.py
@@ -16,9 +16,11 @@
# under the License.
from __future__ import annotations
+import dataclasses
from decimal import Decimal
import pytest
+from pydantic import TypeAdapter
from pydantic_ai.usage import RunUsage
from airflow.providers.common.ai.utils.usage_budget import (
@@ -32,6 +34,12 @@ from airflow.providers.common.ai.utils.usage_budget import (
from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
+# Float measures such as ``audio_seconds``, added to RunUsage in pydantic-ai
2.50.
+_FLOAT_FIELDS = [field.name for field in dataclasses.fields(RunUsage) if
field.type in (float, "float")]
+requires_float_field = pytest.mark.skipif(
+ not _FLOAT_FIELDS, reason="RunUsage has no float field before pydantic-ai
2.50"
+)
+
class TestDumpLoadRunUsage:
def test_round_trips_all_fields_including_decimal_cost(self):
@@ -82,6 +90,40 @@ class TestDumpLoadRunUsage:
load_run_usage(raw, key=USAGE_BUDGET_KEY)
assert USAGE_BUDGET_KEY in str(exc_info.value)
+ def test_usage_validated_by_pydantic_round_trips(self):
+ """
+ The tool-approval resume path rebuilds usage with
``TypeAdapter(RunUsage)``, which
+ normalizes every field to its declared type, e.g. a float field's
default ``0`` to
+ ``0.0``. Whatever shape that produces must survive the task state
store round trip.
+ """
+ usage = TypeAdapter(RunUsage).validate_python(
+ dump_run_usage(RunUsage(requests=2, input_tokens=10,
details={"reasoning": 3}))
+ )
+
+ assert copy_run_usage(usage) == usage
+ assert load_run_usage(dump_run_usage(usage), key=USAGE_BUDGET_KEY) ==
usage
+
+ @requires_float_field
+ @pytest.mark.parametrize("value", [0.0, 1.5, 2], ids=["zero", "fraction",
"int"])
+ def test_float_fields_load_as_float(self, value):
+ raw = dump_run_usage(RunUsage(requests=1))
+ raw.update(dict.fromkeys(_FLOAT_FIELDS, value))
+
+ loaded = load_run_usage(raw, key=USAGE_BUDGET_KEY)
+
+ for name in _FLOAT_FIELDS:
+ assert getattr(loaded, name) == value
+ assert isinstance(getattr(loaded, name), float)
+
+ @requires_float_field
+ @pytest.mark.parametrize("value", ["1.5", True], ids=["string", "bool"])
+ def test_float_field_not_a_number_raises_valueerror_naming_the_key(self,
value):
+ raw = {_FLOAT_FIELDS[0]: value}
+
+ with pytest.raises(ValueError, match="not a number") as exc_info:
+ load_run_usage(raw, key=USAGE_BUDGET_KEY)
+ assert USAGE_BUDGET_KEY in str(exc_info.value)
+
class TestCopyRunUsage:
def test_copy_is_independent_of_the_original(self):