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


##########
providers/common/ai/src/airflow/providers/common/ai/utils/usage.py:
##########
@@ -0,0 +1,228 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Coerce a templated ``usage_limits`` dict into a real ``UsageLimits`` 
instance."""
+
+from __future__ import annotations
+
+import dataclasses
+import math
+import typing
+from collections.abc import Callable
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from pydantic_ai.usage import UsageLimits
+
+
+def _resolve_field_type(field: str, hint: Any) -> type:
+    # Only ``X`` or ``X | None`` are supported shapes -- anything else (a 
Union of
+    # two real types, a parameterized generic, a Literal, ...) has no single
+    # unambiguous coercion target, so it must raise here rather than silently
+    # picking one member and hiding the ambiguity behind a tripwire that never 
fires.
+    args = [arg for arg in typing.get_args(hint) if arg is not type(None)]
+    if not args:
+        resolved = hint
+    elif len(args) == 1:
+        resolved = args[0]
+    else:
+        raise TypeError(f"UsageLimits.{field} has an unsupported annotation 
{hint!r}")
+    if not isinstance(resolved, type):
+        raise TypeError(f"UsageLimits.{field} resolved to a non-type 
{resolved!r}")
+    return resolved
+
+
+def _build_field_types() -> dict[str, type]:
+    # ``pydantic_ai.usage`` uses ``from __future__ import annotations``, so
+    # ``field.type`` is a string; ``get_type_hints`` resolves the real objects.
+    hints = typing.get_type_hints(UsageLimits)
+    return {
+        field.name: _resolve_field_type(field.name, hints[field.name])
+        for field in dataclasses.fields(UsageLimits)
+    }
+
+
+def _coerce_decimal(field: str, value: str) -> Decimal:
+    try:
+        parsed = Decimal(value)
+    except InvalidOperation:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a number (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+    return parsed
+
+
+def _coerce_int(field: str, value: str) -> int:
+    try:
+        return int(value)
+    except ValueError:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be an integer (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+
+
+# Deliberately the same vocabulary as 
``airflow.utils.strings.TRUE_LIKE_VALUES`` so a
+# Dag author who knows Airflow's config parsing already knows this one. Unlike
+# ``to_boolean``, an unrecognized string raises instead of silently becoming 
``False`` --
+# this flag gates a pre-flight token-limit check, and silently turning it off 
would
+# defeat the safeguard this PR exists to add.
+_TRUE_LIKE = {"on", "t", "true", "y", "yes", "1"}
+_FALSE_LIKE = {"off", "f", "false", "n", "no", "0"}
+
+
+def _coerce_bool(field: str, value: str) -> bool:
+    normalized = value.strip().lower()
+    if normalized in _TRUE_LIKE:
+        return True
+    if normalized in _FALSE_LIKE:
+        return False
+    raise ValueError(
+        f"usage_limits[{field!r}] must be one of {sorted(_TRUE_LIKE | 
_FALSE_LIKE)} "
+        f"(got {value!r}); if it is templated, check the rendered value."
+    )
+
+
+_FIELD_TYPES: dict[str, type] = _build_field_types()
+
+# Keyed by the field's declared type rather than the field name so a new
+# ``UsageLimits`` field of an already-supported type (another ``int`` cap, say)
+# needs no change here. A field of an unsupported type raises loudly (see
+# ``_coerce_value``) instead of the templated string silently reaching the
+# dataclass unconverted and failing deep inside pydantic-ai instead.
+_COERCERS: dict[type, Callable[[str, str], Any]] = {
+    Decimal: _coerce_decimal,
+    int: _coerce_int,
+    bool: _coerce_bool,
+}
+
+
+def _is_finite(value: Decimal | int | float) -> bool:
+    # Dispatch by type instead of calling math.isfinite directly on everything:
+    # math.isfinite converts its argument to float first, which overflows a 
large
+    # int into OverflowError and raises outright on a Decimal signaling NaN --
+    # neither looks like "not finite", they look like an unhandled crash. 
Decimal
+    # has no float-sized exponent limit either, so a huge-but-finite Decimal 
must
+    # not be misreported as non-finite just because float can't represent it.
+    if isinstance(value, Decimal):
+        return value.is_finite()
+    if isinstance(value, int):
+        return True
+    return math.isfinite(value)
+
+
+def _validate_range(field: str, value: Decimal | int | float) -> None:
+    if not _is_finite(value):
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a finite number (got {value!r}); 
"
+            "a non-finite value would silently disable that limit."
+        )
+    if value < 0:
+        raise ValueError(f"usage_limits[{field!r}] must not be negative (got 
{value!r})")
+
+
+def _unknown_field_message(field: str) -> str:
+    valid_fields = ", ".join(sorted(_FIELD_TYPES))
+    return f"usage_limits has no field {field!r}; valid fields are: 
{valid_fields}"
+
+
+def _truncated_repr(value: Any, limit: int = 100) -> str:
+    # A container-shape error on a templated field usually means the render
+    # produced something that merely looks right (e.g. a long string that reads
+    # like a dict literal) -- the author needs to see what actually came out, 
not
+    # just its type. Truncate so a large rendered blob doesn't bloat the 
exception.
+    text = repr(value)
+    return text if len(text) <= limit else f"{text[:limit]}..."
+
+
+def _coerce_value(field: str, value: Any) -> Any:
+    # ``None`` is always the author's explicit choice to disable that limit --
+    # Jinja never produces ``None`` from a string template -- so it passes 
through.

Review Comment:
   With `render_template_as_native_obj=True` Jinja does produce a real `None`: 
`{{ params.budget }}` where `params.budget` is `None` renders to `None`, not 
`"None"`. The `is None` check below then passes it straight through and the cap 
is silently off, which is the one outcome the docstring says this module exists 
to prevent. Under the default environment the comment holds, so this only bites 
native-mode Dags, but a rendered `None` and an author-written `None` are 
indistinguishable at this point.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/usage.py:
##########
@@ -0,0 +1,228 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Coerce a templated ``usage_limits`` dict into a real ``UsageLimits`` 
instance."""
+
+from __future__ import annotations
+
+import dataclasses
+import math
+import typing
+from collections.abc import Callable
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from pydantic_ai.usage import UsageLimits
+
+
+def _resolve_field_type(field: str, hint: Any) -> type:
+    # Only ``X`` or ``X | None`` are supported shapes -- anything else (a 
Union of
+    # two real types, a parameterized generic, a Literal, ...) has no single
+    # unambiguous coercion target, so it must raise here rather than silently
+    # picking one member and hiding the ambiguity behind a tripwire that never 
fires.
+    args = [arg for arg in typing.get_args(hint) if arg is not type(None)]
+    if not args:
+        resolved = hint
+    elif len(args) == 1:
+        resolved = args[0]
+    else:
+        raise TypeError(f"UsageLimits.{field} has an unsupported annotation 
{hint!r}")
+    if not isinstance(resolved, type):
+        raise TypeError(f"UsageLimits.{field} resolved to a non-type 
{resolved!r}")
+    return resolved
+
+
+def _build_field_types() -> dict[str, type]:
+    # ``pydantic_ai.usage`` uses ``from __future__ import annotations``, so
+    # ``field.type`` is a string; ``get_type_hints`` resolves the real objects.
+    hints = typing.get_type_hints(UsageLimits)
+    return {
+        field.name: _resolve_field_type(field.name, hints[field.name])
+        for field in dataclasses.fields(UsageLimits)
+    }
+
+
+def _coerce_decimal(field: str, value: str) -> Decimal:
+    try:
+        parsed = Decimal(value)
+    except InvalidOperation:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a number (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+    return parsed
+
+
+def _coerce_int(field: str, value: str) -> int:
+    try:
+        return int(value)
+    except ValueError:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be an integer (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+
+
+# Deliberately the same vocabulary as 
``airflow.utils.strings.TRUE_LIKE_VALUES`` so a
+# Dag author who knows Airflow's config parsing already knows this one. Unlike
+# ``to_boolean``, an unrecognized string raises instead of silently becoming 
``False`` --
+# this flag gates a pre-flight token-limit check, and silently turning it off 
would
+# defeat the safeguard this PR exists to add.
+_TRUE_LIKE = {"on", "t", "true", "y", "yes", "1"}
+_FALSE_LIKE = {"off", "f", "false", "n", "no", "0"}
+
+
+def _coerce_bool(field: str, value: str) -> bool:
+    normalized = value.strip().lower()
+    if normalized in _TRUE_LIKE:
+        return True
+    if normalized in _FALSE_LIKE:
+        return False
+    raise ValueError(
+        f"usage_limits[{field!r}] must be one of {sorted(_TRUE_LIKE | 
_FALSE_LIKE)} "
+        f"(got {value!r}); if it is templated, check the rendered value."
+    )
+
+
+_FIELD_TYPES: dict[str, type] = _build_field_types()

Review Comment:
   This runs at import, and `_resolve_field_type` raises on any annotation that 
is not `X` or `X | None`. All six operator modules import this one at module 
scope, so a future `UsageLimits` field typed `Literal[...]` or `list[int] | 
None` would make every common.ai operator unimportable and every Dag using the 
provider fail to parse, including Dags that never set `usage_limits`. Nothing 
trips it on the versions I checked, but the floor has no upper bound and 
pydantic-ai ships a minor most weekdays. Resolving the field type lazily inside 
`_coerce_value` keeps the loud failure and scopes it to authors who actually 
pass that field.



##########
providers/common/ai/docs/operators/agent.rst:
##########
@@ -484,10 +484,27 @@ Parameters
   See :ref:`capabilities-passthrough` for how to enable pydantic-ai 
capabilities
   such as ``Thinking``, ``WebSearch``, and ``ImageGeneration``.
 - ``usage_limits``: Optional pydantic-ai ``UsageLimits`` enforced on every
-  agent run (initial run, durable replay, and HITL regeneration). Use it to
-  cap requests, tokens, or tool calls per task -- agents are particularly
-  prone to runaway tool loops, so ``tool_calls_limit`` is a useful guardrail.
-  See :ref:`howto/operator:llm` for an example. Default ``None``.
+  agent run (initial run, durable replay, and HITL regeneration), or a ``dict``
+  of the same fields -- the dict form is templated via Jinja, then coerced per
+  field type, failing the task with a ``ValueError`` naming the field if a
+  rendered value doesn't parse. Use it to cap requests, tokens, or tool calls
+  per task -- agents are particularly prone to runaway tool loops, so
+  ``tool_calls_limit`` is a useful guardrail. It also supports a per-run USD
+  ``cost_limit``; see :ref:`howto/operator:llm` for the caveats (not a hard
+  guarantee, silently inert for unpriced models) and an example. Default
+  ``None``.
+
+  .. warning::
+     With ``durable=True``, a task retry replays cached model steps instead of
+     re-calling the model -- but pydantic-ai still adds each replayed step's

Review Comment:
   `CachingModel.request` is the one place that knows the response is a replay 
(it bumps `counter.replayed_model` right before returning the cached object), 
and `ModelResponse` is a dataclass, so `dataclasses.replace(cached, 
usage=RequestUsage())` there would make a retry's budget reflect what that 
attempt actually spent. It would fix the same double-count for `request_limit`, 
`tool_calls_limit` and the token limits, which have it too but are not 
mentioned here. The cost is that the run-summary log would then under-report 
the conversation's real token counts. Is documenting it preferred over fixing 
it?



##########
providers/common/ai/docs/operators/llm.rst:
##########
@@ -119,16 +119,46 @@ calls within a single task.
     :start-after: [START howto_operator_llm_usage_limits]
     :end-before: [END howto_operator_llm_usage_limits]
 
+A plain ``dict`` can be passed instead of a ``UsageLimits`` instance, which 
lets
+Jinja template individual fields -- e.g. a per-run cost cap driven by an 
Airflow
+Variable so the budget can change per environment without editing the Dag:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm.py
+    :language: python
+    :start-after: [START howto_operator_llm_templated_usage_limits]
+    :end-before: [END howto_operator_llm_templated_usage_limits]
+
+Each dict value is rendered by Jinja like any other ``template_fields`` entry,
+then coerced to that field's type (``Decimal``, ``int``, or ``bool``). A value
+that doesn't parse -- an unset Variable renders to ``""``, a typo renders to a
+non-numeric string -- fails the task with a ``ValueError`` naming the field and
+the rendered value, instead of silently disabling the limit. A ``UsageLimits``
+instance passed directly is used as-is and is not templated or validated.
+
 Common knobs on ``UsageLimits``:
 
 - ``request_limit`` — max model requests per run (caps retry/tool-loop 
blow-ups).
   pydantic-ai applies a default of ``50`` when ``UsageLimits()`` is constructed
   without an explicit value, so passing 
``UsageLimits(input_tokens_limit=4_000)``
-  silently inherits that 50-request cap. Set ``request_limit=None`` to disable
-  it explicitly when you only want a token cap.
+  (or the dict form ``{"input_tokens_limit": 4_000}``) silently inherits that
+  50-request cap. Set ``request_limit=None`` explicitly when you only want a
+  token cap.
 - ``input_tokens_limit`` / ``output_tokens_limit`` — per-run token caps.
 - ``total_tokens_limit`` — combined input + output cap.
 - ``tool_calls_limit`` — max tool invocations (``AgentOperator`` only).
+- ``cost_limit`` — a ``Decimal`` cap on the run's estimated USD cost. This is 
**not** a
+  hard guarantee against overspend: the response that crosses the limit has 
already been
+  produced and billed — pydantic-ai checks the accumulated cost *after* each 
response and
+  then fails the run with ``UsageLimitExceeded``. It protects you from further 
spend, not
+  from the request that broke the budget; even a single-request run fails as 
soon as that
+  request's cost pushes the total over the limit. For self-hosted or unknown
+  models (e.g. Ollama, custom endpoints) pydantic-ai cannot price the 
response, so cost
+  is ``None`` and ``cost_limit`` silently has no effect (a 
``CostNotFoundWarning`` is

Review Comment:
   This still reads `silently has no effect` immediately before saying a 
`CostNotFoundWarning` is emitted. Looks like the suggestion you accepted above 
did not make it into the last push.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/usage.py:
##########
@@ -0,0 +1,228 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Coerce a templated ``usage_limits`` dict into a real ``UsageLimits`` 
instance."""
+
+from __future__ import annotations
+
+import dataclasses
+import math
+import typing
+from collections.abc import Callable
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from pydantic_ai.usage import UsageLimits
+
+
+def _resolve_field_type(field: str, hint: Any) -> type:
+    # Only ``X`` or ``X | None`` are supported shapes -- anything else (a 
Union of
+    # two real types, a parameterized generic, a Literal, ...) has no single
+    # unambiguous coercion target, so it must raise here rather than silently
+    # picking one member and hiding the ambiguity behind a tripwire that never 
fires.
+    args = [arg for arg in typing.get_args(hint) if arg is not type(None)]
+    if not args:
+        resolved = hint
+    elif len(args) == 1:
+        resolved = args[0]
+    else:
+        raise TypeError(f"UsageLimits.{field} has an unsupported annotation 
{hint!r}")
+    if not isinstance(resolved, type):
+        raise TypeError(f"UsageLimits.{field} resolved to a non-type 
{resolved!r}")
+    return resolved
+
+
+def _build_field_types() -> dict[str, type]:
+    # ``pydantic_ai.usage`` uses ``from __future__ import annotations``, so
+    # ``field.type`` is a string; ``get_type_hints`` resolves the real objects.
+    hints = typing.get_type_hints(UsageLimits)
+    return {
+        field.name: _resolve_field_type(field.name, hints[field.name])
+        for field in dataclasses.fields(UsageLimits)
+    }
+
+
+def _coerce_decimal(field: str, value: str) -> Decimal:
+    try:
+        parsed = Decimal(value)
+    except InvalidOperation:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a number (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+    return parsed
+
+
+def _coerce_int(field: str, value: str) -> int:
+    try:
+        return int(value)
+    except ValueError:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be an integer (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+
+
+# Deliberately the same vocabulary as 
``airflow.utils.strings.TRUE_LIKE_VALUES`` so a
+# Dag author who knows Airflow's config parsing already knows this one. Unlike
+# ``to_boolean``, an unrecognized string raises instead of silently becoming 
``False`` --
+# this flag gates a pre-flight token-limit check, and silently turning it off 
would
+# defeat the safeguard this PR exists to add.
+_TRUE_LIKE = {"on", "t", "true", "y", "yes", "1"}
+_FALSE_LIKE = {"off", "f", "false", "n", "no", "0"}
+
+
+def _coerce_bool(field: str, value: str) -> bool:
+    normalized = value.strip().lower()
+    if normalized in _TRUE_LIKE:
+        return True
+    if normalized in _FALSE_LIKE:
+        return False
+    raise ValueError(
+        f"usage_limits[{field!r}] must be one of {sorted(_TRUE_LIKE | 
_FALSE_LIKE)} "
+        f"(got {value!r}); if it is templated, check the rendered value."
+    )
+
+
+_FIELD_TYPES: dict[str, type] = _build_field_types()
+
+# Keyed by the field's declared type rather than the field name so a new
+# ``UsageLimits`` field of an already-supported type (another ``int`` cap, say)
+# needs no change here. A field of an unsupported type raises loudly (see
+# ``_coerce_value``) instead of the templated string silently reaching the
+# dataclass unconverted and failing deep inside pydantic-ai instead.
+_COERCERS: dict[type, Callable[[str, str], Any]] = {
+    Decimal: _coerce_decimal,
+    int: _coerce_int,
+    bool: _coerce_bool,
+}
+
+
+def _is_finite(value: Decimal | int | float) -> bool:
+    # Dispatch by type instead of calling math.isfinite directly on everything:
+    # math.isfinite converts its argument to float first, which overflows a 
large
+    # int into OverflowError and raises outright on a Decimal signaling NaN --
+    # neither looks like "not finite", they look like an unhandled crash. 
Decimal
+    # has no float-sized exponent limit either, so a huge-but-finite Decimal 
must
+    # not be misreported as non-finite just because float can't represent it.
+    if isinstance(value, Decimal):
+        return value.is_finite()
+    if isinstance(value, int):
+        return True
+    return math.isfinite(value)
+
+
+def _validate_range(field: str, value: Decimal | int | float) -> None:
+    if not _is_finite(value):
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a finite number (got {value!r}); 
"
+            "a non-finite value would silently disable that limit."
+        )
+    if value < 0:
+        raise ValueError(f"usage_limits[{field!r}] must not be negative (got 
{value!r})")
+
+
+def _unknown_field_message(field: str) -> str:
+    valid_fields = ", ".join(sorted(_FIELD_TYPES))
+    return f"usage_limits has no field {field!r}; valid fields are: 
{valid_fields}"
+
+
+def _truncated_repr(value: Any, limit: int = 100) -> str:
+    # A container-shape error on a templated field usually means the render
+    # produced something that merely looks right (e.g. a long string that reads
+    # like a dict literal) -- the author needs to see what actually came out, 
not
+    # just its type. Truncate so a large rendered blob doesn't bloat the 
exception.
+    text = repr(value)
+    return text if len(text) <= limit else f"{text[:limit]}..."
+
+
+def _coerce_value(field: str, value: Any) -> Any:
+    # ``None`` is always the author's explicit choice to disable that limit --
+    # Jinja never produces ``None`` from a string template -- so it passes 
through.
+    if value is None:
+        return value
+
+    field_type = _FIELD_TYPES[field]
+    # Only ``str`` values are converted: Jinja only ever renders a scalar leaf 
to
+    # ``str``, so a non-``str`` value is exactly what the author wrote (a 
literal
+    # ``Decimal``, ``int``, or ``bool``) and is passed through unchanged.
+    if isinstance(value, str):
+        coercer = _COERCERS.get(field_type)
+        if coercer is None:
+            type_name = getattr(field_type, "__name__", field_type)
+            raise ValueError(
+                f"usage_limits[{field!r}] does not support templated (string) 
values "
+                f"(got {value!r}); pass a {type_name} value instead."
+            )
+        value = coercer(field, value)
+    elif field_type is Decimal and isinstance(value, (int, float)):
+        # Decimal(str(x)), never Decimal(x): the latter bakes in binary-float 
noise
+        # for a value like 0.1, and normalizing through str routes inf/nan 
through
+        # the same finite check below as the templated-string path -- a bare
+        # ``float`` must not bypass the one safety promise this module makes.
+        value = _coerce_decimal(field, str(value))
+
+    if field_type in (Decimal, int):
+        _validate_range(field, value)
+    return value
+
+
+def coerce_usage_limits(usage_limits: UsageLimits | dict[str, Any] | None) -> 
UsageLimits | None:
+    """
+    Coerce a rendered ``usage_limits`` dict into a real ``UsageLimits`` 
instance.
+
+    A ``UsageLimits`` instance has neither ``resolve`` nor 
``template_fields``, so
+    Airflow's template walk is a no-op on it even though ``usage_limits`` is 
in the
+    operators' ``template_fields``. Passing a plain dict instead lets every 
field be
+    templated, but the rendered value is then not in the Dag author's control: 
an
+    unset Airflow Variable renders to ``""``, and a typo renders to an 
arbitrary

Review Comment:
   An unset Variable does not render to `""`. `VariableAccessor.__getattr__` 
calls `_get_variable`, which raises `AirflowRuntimeError`, and Jinja's 
sandboxed getattr only swallows `AttributeError`, so rendering fails before 
`coerce_usage_limits` is reached. The case that actually gives you `""` is a 
Variable that exists with an empty value. The same sentence is in 
`operators/llm.py:86`, `operators/agent.py:154` and 
`docs/operators/llm.rst:133`, and it is the open question on 
`example_dags/example_llm.py:169`.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/usage.py:
##########
@@ -0,0 +1,228 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Coerce a templated ``usage_limits`` dict into a real ``UsageLimits`` 
instance."""
+
+from __future__ import annotations
+
+import dataclasses
+import math
+import typing
+from collections.abc import Callable
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from pydantic_ai.usage import UsageLimits
+
+
+def _resolve_field_type(field: str, hint: Any) -> type:
+    # Only ``X`` or ``X | None`` are supported shapes -- anything else (a 
Union of
+    # two real types, a parameterized generic, a Literal, ...) has no single
+    # unambiguous coercion target, so it must raise here rather than silently
+    # picking one member and hiding the ambiguity behind a tripwire that never 
fires.
+    args = [arg for arg in typing.get_args(hint) if arg is not type(None)]
+    if not args:
+        resolved = hint
+    elif len(args) == 1:
+        resolved = args[0]
+    else:
+        raise TypeError(f"UsageLimits.{field} has an unsupported annotation 
{hint!r}")
+    if not isinstance(resolved, type):
+        raise TypeError(f"UsageLimits.{field} resolved to a non-type 
{resolved!r}")
+    return resolved
+
+
+def _build_field_types() -> dict[str, type]:
+    # ``pydantic_ai.usage`` uses ``from __future__ import annotations``, so
+    # ``field.type`` is a string; ``get_type_hints`` resolves the real objects.
+    hints = typing.get_type_hints(UsageLimits)
+    return {
+        field.name: _resolve_field_type(field.name, hints[field.name])
+        for field in dataclasses.fields(UsageLimits)
+    }
+
+
+def _coerce_decimal(field: str, value: str) -> Decimal:
+    try:
+        parsed = Decimal(value)
+    except InvalidOperation:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a number (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+    return parsed
+
+
+def _coerce_int(field: str, value: str) -> int:
+    try:
+        return int(value)
+    except ValueError:
+        raise ValueError(
+            f"usage_limits[{field!r}] must be an integer (got {value!r}); "
+            "if it is templated, check the rendered value."
+        ) from None
+
+
+# Deliberately the same vocabulary as 
``airflow.utils.strings.TRUE_LIKE_VALUES`` so a
+# Dag author who knows Airflow's config parsing already knows this one. Unlike
+# ``to_boolean``, an unrecognized string raises instead of silently becoming 
``False`` --
+# this flag gates a pre-flight token-limit check, and silently turning it off 
would
+# defeat the safeguard this PR exists to add.
+_TRUE_LIKE = {"on", "t", "true", "y", "yes", "1"}
+_FALSE_LIKE = {"off", "f", "false", "n", "no", "0"}
+
+
+def _coerce_bool(field: str, value: str) -> bool:
+    normalized = value.strip().lower()
+    if normalized in _TRUE_LIKE:
+        return True
+    if normalized in _FALSE_LIKE:
+        return False
+    raise ValueError(
+        f"usage_limits[{field!r}] must be one of {sorted(_TRUE_LIKE | 
_FALSE_LIKE)} "
+        f"(got {value!r}); if it is templated, check the rendered value."
+    )
+
+
+_FIELD_TYPES: dict[str, type] = _build_field_types()
+
+# Keyed by the field's declared type rather than the field name so a new
+# ``UsageLimits`` field of an already-supported type (another ``int`` cap, say)
+# needs no change here. A field of an unsupported type raises loudly (see
+# ``_coerce_value``) instead of the templated string silently reaching the
+# dataclass unconverted and failing deep inside pydantic-ai instead.
+_COERCERS: dict[type, Callable[[str, str], Any]] = {
+    Decimal: _coerce_decimal,
+    int: _coerce_int,
+    bool: _coerce_bool,
+}
+
+
+def _is_finite(value: Decimal | int | float) -> bool:
+    # Dispatch by type instead of calling math.isfinite directly on everything:
+    # math.isfinite converts its argument to float first, which overflows a 
large
+    # int into OverflowError and raises outright on a Decimal signaling NaN --
+    # neither looks like "not finite", they look like an unhandled crash. 
Decimal
+    # has no float-sized exponent limit either, so a huge-but-finite Decimal 
must
+    # not be misreported as non-finite just because float can't represent it.
+    if isinstance(value, Decimal):
+        return value.is_finite()
+    if isinstance(value, int):
+        return True
+    return math.isfinite(value)
+
+
+def _validate_range(field: str, value: Decimal | int | float) -> None:
+    if not _is_finite(value):
+        raise ValueError(
+            f"usage_limits[{field!r}] must be a finite number (got {value!r}); 
"
+            "a non-finite value would silently disable that limit."
+        )
+    if value < 0:
+        raise ValueError(f"usage_limits[{field!r}] must not be negative (got 
{value!r})")
+
+
+def _unknown_field_message(field: str) -> str:
+    valid_fields = ", ".join(sorted(_FIELD_TYPES))
+    return f"usage_limits has no field {field!r}; valid fields are: 
{valid_fields}"
+
+
+def _truncated_repr(value: Any, limit: int = 100) -> str:
+    # A container-shape error on a templated field usually means the render
+    # produced something that merely looks right (e.g. a long string that reads
+    # like a dict literal) -- the author needs to see what actually came out, 
not
+    # just its type. Truncate so a large rendered blob doesn't bloat the 
exception.
+    text = repr(value)
+    return text if len(text) <= limit else f"{text[:limit]}..."
+
+
+def _coerce_value(field: str, value: Any) -> Any:
+    # ``None`` is always the author's explicit choice to disable that limit --
+    # Jinja never produces ``None`` from a string template -- so it passes 
through.
+    if value is None:
+        return value
+
+    field_type = _FIELD_TYPES[field]
+    # Only ``str`` values are converted: Jinja only ever renders a scalar leaf 
to
+    # ``str``, so a non-``str`` value is exactly what the author wrote (a 
literal
+    # ``Decimal``, ``int``, or ``bool``) and is passed through unchanged.
+    if isinstance(value, str):

Review Comment:
   Only strings get coerced, so every other value reaches `UsageLimits` with 
just the finite/negative check behind it. `bool` is an `int` subclass, so 
`{"request_limit": False}` builds `UsageLimits(request_limit=False)` and the 
run then dies before the first model call with `UsageLimitExceeded: The next 
request would exceed the request_limit of False`. That is the 
confusing-failure-inside-pydantic-ai case this module is here to stop, and 
`Variable.get(..., deserialize_json=True)` with `false` written where `null` 
was meant gets you there. A plain float lands on the int fields the same way, 
so `request_limit` can end up non-integral.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/logging.py:
##########
@@ -48,9 +49,10 @@ def log_run_summary(logger: Logger | logging.Logger, result: 
AgentRunResult[Any]
         usage.output_tokens,
         usage.total_tokens,
     )
+    if usage.cost is not None:
+        logger.info("LLM run cost: $%s (USD, best-effort)", usage.cost)

Review Comment:
   `%s` on a small `Decimal` gives scientific notation, so a cheap single-call 
run logs `LLM run cost: $7.5E-7`. `format(cost, "f")` prints it as a plain 
decimal instead.



##########
providers/common/ai/tests/unit/common/ai/durable/test_replay_cost.py:
##########
@@ -0,0 +1,132 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Empirical check of whether durable replay double-counts cost against 
``cost_limit``.
+
+pydantic-ai's graph appends *every* model response's usage to the run's
+``RunUsage`` in ``_agent_graph.py::_append_response`` -- it cannot distinguish 
a
+response that came from a live model call from one ``CachingModel`` replayed
+from the durable cache. Each Airflow task attempt starts a fresh ``RunUsage``
+(a new ``agent.run`` call), so a step that was already paid for in a prior,
+crashed attempt gets its cost added again to the retry's own usage total --
+even though the retry made zero new model calls for that step. These tests
+exercise the real ``CachingModel`` + ``DurableStorage`` + pydantic-ai ``Agent``
+stack (no mocked cost arithmetic) to confirm this, rather than relying on
+reading ``_agent_graph.py`` / ``_cost.py`` and assuming.
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from unittest.mock import patch
+
+import pytest
+from pydantic_ai import Agent
+from pydantic_ai.exceptions import UsageLimitExceeded
+from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
+from pydantic_ai.models.function import AgentInfo, FunctionModel
+from pydantic_ai.usage import RequestUsage, UsageLimits
+
+from airflow.providers.common.ai.durable.caching_model import CachingModel
+from airflow.providers.common.ai.durable.step_counter import DurableStepCounter
+from airflow.providers.common.ai.durable.storage import DurableStorage
+from airflow.sdk import ObjectStoragePath
+
+PRICED_COST = Decimal("0.10")
+
+
+def _build_priced_response(messages: list[ModelMessage], info: AgentInfo) -> 
ModelResponse:
+    return ModelResponse(
+        parts=[TextPart(content="the answer")],
+        usage=RequestUsage(input_tokens=100, output_tokens=50, 
cost=PRICED_COST),
+    )
+
+
[email protected]
+def durable_storage(tmp_path):
+    """A real, file-backed DurableStorage -- exercises the actual JSON 
round-trip."""
+    with patch("airflow.providers.common.ai.durable.storage._get_base_path") 
as mock_base:
+        mock_base.return_value = 
ObjectStoragePath(f"file://{tmp_path.as_posix()}")
+        yield DurableStorage(dag_id="dag", task_id="task", run_id="run_1", 
map_index=-1)
+
+
+async def _run_one_attempt(storage: DurableStorage, *, cost_limit: Decimal | 
None = None):
+    """Simulate one Airflow task attempt: fresh Agent + fresh 
DurableStepCounter, shared cache."""
+    counter = DurableStepCounter()
+    caching = CachingModel(FunctionModel(_build_priced_response), 
storage=storage, counter=counter)
+    agent = Agent(model=caching)
+    result = await agent.run("What is the answer?", 
usage_limits=UsageLimits(cost_limit=cost_limit))
+    return result, counter
+
+
+def _reopen_storage() -> DurableStorage:
+    """Build a fresh ``DurableStorage`` for the same dag/task/run -- simulates 
a new Airflow
+    task attempt (new process) reloading the durable cache from disk via the 
public
+    constructor, rather than reaching into the private ``_cache`` attribute."""
+    return DurableStorage(dag_id="dag", task_id="task", run_id="run_1", 
map_index=-1)
+
+
+class TestDurableReplayCostDuplication:
+    @pytest.mark.asyncio
+    async def test_replayed_step_cost_is_recounted_on_retry(self, 
durable_storage):
+        """A second attempt that only replays cached steps still reports the 
replayed cost
+        as its own usage -- pydantic-ai cannot tell a replay from a live 
call."""
+        result1, counter1 = await _run_one_attempt(durable_storage)
+        assert counter1.cached_model == 1
+        assert counter1.replayed_model == 0
+        assert result1.usage.cost == PRICED_COST
+
+        # New attempt: fresh process, so the cache is reloaded from disk via a 
new
+        # DurableStorage -- this is what actually happens on an Airflow task 
retry.
+        result2, counter2 = await _run_one_attempt(_reopen_storage())
+
+        # Zero new model calls this attempt ...
+        assert counter2.cached_model == 0
+        assert counter2.replayed_model == 1
+        # ... yet the replayed step's cost is counted again, identically to 
attempt 1.
+        assert result2.usage.cost == PRICED_COST
+
+    @pytest.mark.asyncio
+    async def 
test_retry_with_zero_new_spend_still_raises_usage_limit_exceeded(self, 
durable_storage):
+        """A retry that makes no new model calls can still raise 
UsageLimitExceeded,
+        purely from replayed cost -- because check_cost() sees the run's 
cumulative
+        usage, not "money spent in this attempt"."""
+        # Attempt 1 stays comfortably under budget so it completes normally.
+        await _run_one_attempt(durable_storage, cost_limit=PRICED_COST * 2)
+
+        # Attempt 2 sets a limit below the already-paid-for replayed cost: 
zero new
+        # spend, yet the replayed step alone pushes the cumulative usage over 
it.
+        cost_limit = PRICED_COST / 2
+        with pytest.raises(UsageLimitExceeded):

Review Comment:
   Nothing here asserts the "zero new model calls" half of the claim. 
`_run_one_attempt` returns the counter, but it is discarded when the exception 
propagates, so this passes just as well on a cold cache where attempt 2 makes a 
live call. Holding the counter in the test scope and asserting `cached_model == 
0` and `replayed_model == 1` next to the `raises` would make it test its own 
docstring.



##########
providers/common/ai/tests/unit/common/ai/conftest.py:
##########
@@ -31,15 +31,22 @@ def isolate_hook_lineage_collector(hook_lineage_collector):
     return None
 
 
-def make_mock_run_result(output):
+def make_mock_run_result(output, *, cost=None):

Review Comment:
   This helper has no callers: every one of the 12 test modules defines its own 
private `_make_mock_run_result`, which is why the same `cost=None` fix had to 
be hand-applied 12 times in this PR. The hunk is inert as written, and the next 
test module copied from the local pattern will log `LLM run cost: $<MagicMock 
...>` with nothing to catch it. Worth either pointing the modules at this one 
or dropping it.



##########
providers/common/ai/docs/operators/llm.rst:
##########
@@ -119,16 +119,46 @@ calls within a single task.
     :start-after: [START howto_operator_llm_usage_limits]
     :end-before: [END howto_operator_llm_usage_limits]
 
+A plain ``dict`` can be passed instead of a ``UsageLimits`` instance, which 
lets
+Jinja template individual fields -- e.g. a per-run cost cap driven by an 
Airflow
+Variable so the budget can change per environment without editing the Dag:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm.py
+    :language: python
+    :start-after: [START howto_operator_llm_templated_usage_limits]
+    :end-before: [END howto_operator_llm_templated_usage_limits]
+
+Each dict value is rendered by Jinja like any other ``template_fields`` entry,
+then coerced to that field's type (``Decimal``, ``int``, or ``bool``). A value
+that doesn't parse -- an unset Variable renders to ``""``, a typo renders to a
+non-numeric string -- fails the task with a ``ValueError`` naming the field and
+the rendered value, instead of silently disabling the limit. A ``UsageLimits``
+instance passed directly is used as-is and is not templated or validated.
+
 Common knobs on ``UsageLimits``:
 
 - ``request_limit`` — max model requests per run (caps retry/tool-loop 
blow-ups).
   pydantic-ai applies a default of ``50`` when ``UsageLimits()`` is constructed
   without an explicit value, so passing 
``UsageLimits(input_tokens_limit=4_000)``
-  silently inherits that 50-request cap. Set ``request_limit=None`` to disable
-  it explicitly when you only want a token cap.
+  (or the dict form ``{"input_tokens_limit": 4_000}``) silently inherits that
+  50-request cap. Set ``request_limit=None`` explicitly when you only want a
+  token cap.
 - ``input_tokens_limit`` / ``output_tokens_limit`` — per-run token caps.
 - ``total_tokens_limit`` — combined input + output cap.
 - ``tool_calls_limit`` — max tool invocations (``AgentOperator`` only).
+- ``cost_limit`` — a ``Decimal`` cap on the run's estimated USD cost. This is 
**not** a
+  hard guarantee against overspend: the response that crosses the limit has 
already been
+  produced and billed — pydantic-ai checks the accumulated cost *after* each 
response and
+  then fails the run with ``UsageLimitExceeded``. It protects you from further 
spend, not
+  from the request that broke the budget; even a single-request run fails as 
soon as that
+  request's cost pushes the total over the limit. For self-hosted or unknown
+  models (e.g. Ollama, custom endpoints) pydantic-ai cannot price the 
response, so cost
+  is ``None`` and ``cost_limit`` silently has no effect (a 
``CostNotFoundWarning`` is
+  emitted instead of a failure). And like the other knobs above, setting 
``cost_limit``
+  alone still inherits the ``request_limit=50`` default — see the 
``request_limit`` note
+  above. Note that ``cost_limit`` only caps the operator's own LLM calls --
+  the meta-agent that ``LLMRetryPolicy`` runs to classify a failed task is a 
separate,
+  uncapped LLM call; see :doc:`../retry_policies`.

Review Comment:
   `pydantic-ai-harness` ships a `SpendLimits` capability that covers two of 
the gaps this bullet documents: the budget is carried across runs (day / month 
/ total windows, optionally in a shared Redis counter), so a run is refused 
before its first request instead of every run getting the full cap again, and 
it prices responses through `genai-prices` with `on_unpriced='raise'` rather 
than going quiet when the provider reports no cost. `agent_params` is forwarded 
to the `Agent` constructor, so `capabilities=[SpendLimits(...)]` already works, 
though harness only arrives via the `code-mode` extra today. Worth pointing 
this bullet at it for anyone who wants a budget rather than a per-run ceiling? 
https://pydantic.dev/docs/ai/harness/spend/



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