This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun 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 226de8a1911 Publish usage, run id and model name from LLMOperator's 
subclasses (#75208)
226de8a1911 is described below

commit 226de8a19117856d2ea8587b03692031aa6b7239
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Oct 29 10:58:32 2026 +0100

    Publish usage, run id and model name from LLMOperator's subclasses (#75208)
    
    * Publish usage, run id and model name from LLMOperator's subclasses
    
    LLMBranchOperator, LLMSQLQueryOperator, LLMSchemaCompareOperator and
    LLMFileAnalysisOperator override execute() without calling
    super().execute(), so they never pushed the usage, run_id and model name
    XComs, never stamped the task-instance identity on their GenAI spans,
    and had no AI Model tab. Calling super() is not an option: it would also
    apply LLMOperator's decision record, confidence gate and review body,
    which these operators replace or don't have. The reporting now lives in
    _run_llm, the one place every execute() runs the agent, so the
    subclasses, and any future one, report the same metadata as LLMOperator.
    
    * Clarify which operators publish the run telemetry
    
    The Scope bullet read as if agents traced through agent_framework_tracing
    were excluded from everything, when they still get the airflow.* identity
    attributes and only miss the XComs.
    
    * Rename the agent call under _run_llm to _run_agent_within_budget
    
    Both methods were named after running, which hid that _run_llm is the
    entry point every execute() calls and the other one only runs the agent
    under the usage budget.
    
    * Make the subclass telemetry tests catch a lost run id or identity stamp
    
    The subclass tests passed even when _run_llm forwarded no run id or skipped 
the
    identity stamp, and the AI Model tab's operator list could miss a new 
LLMOperator
    subclass unnoticed. The tests now fail in each of those cases.
    
    * Point the subclass docs at the run XComs and spec the test task instance
    
    The SQL, branch, file-analysis and schema-compare pages stopped at the log
    summary, so a reader of those pages could not tell the operators also push
    usage, run_id and the model name. The XCom tests used an unspecced task
    instance, so any attribute the identity stamping read would silently pass.
    
    * Fix the docs build on the run XComs pointers
    
    The anchor sits inside a bullet rather than before a section, so Sphinx has 
no title to use for a bare reference to it.
    
    * Link the subclass pages to the observability page instead of a bullet 
anchor
    
    Every other operator page, llm.rst included, points at the observability 
page as a whole; an anchor inside a bullet was a one-off pattern.
---
 providers/common/ai/docs/features.rst              |   4 +-
 providers/common/ai/docs/observability.rst         |  43 ++++-----
 providers/common/ai/docs/operators/llm_branch.rst  |   6 +-
 .../common/ai/docs/operators/llm_file_analysis.rst |  10 ++
 .../ai/docs/operators/llm_schema_compare.rst       |   3 +
 providers/common/ai/docs/operators/llm_sql.rst     |   3 +
 .../airflow/providers/common/ai/operators/llm.py   | 101 ++++++++++++---------
 .../providers/common/ai/plugins/model_panel.py     |  14 ++-
 .../common/ai/tests/unit/common/ai/conftest.py     |  57 ++++++++++++
 .../unit/common/ai/decorators/test_llm_branch.py   |  12 ++-
 .../common/ai/decorators/test_llm_file_analysis.py |   8 +-
 .../ai/decorators/test_llm_schema_compare.py       |   2 +-
 .../unit/common/ai/decorators/test_llm_sql.py      |  12 ++-
 .../unit/common/ai/operators/test_llm_branch.py    |  84 +++++++++++------
 .../common/ai/operators/test_llm_file_analysis.py  |  42 +++++++--
 .../common/ai/operators/test_llm_schema_compare.py |  26 +++++-
 .../tests/unit/common/ai/operators/test_llm_sql.py |  18 +++-
 .../unit/common/ai/plugins/test_model_panel.py     |  34 ++++---
 18 files changed, 339 insertions(+), 140 deletions(-)

diff --git a/providers/common/ai/docs/features.rst 
b/providers/common/ai/docs/features.rst
index 7a461dec8ae..9ecee96b711 100644
--- a/providers/common/ai/docs/features.rst
+++ b/providers/common/ai/docs/features.rst
@@ -302,8 +302,8 @@ See what ran
      - Exports model and tool calls as OpenTelemetry spans.
      - None
    * - :ref:`AI Model tab <howto/observability:model-tab>`
-     - Shows the model on the task instance page of ``@task.llm`` and 
``@task.agent``
-       tasks, and their token counts and an estimated cost.
+     - Shows the model on the task instance page of ``@task.agent`` and every 
``@task.llm*``
+       task except ``@task.llm_batch``, and their token counts and an 
estimated cost.
      - None
 
 .. toctree::
diff --git a/providers/common/ai/docs/observability.rst 
b/providers/common/ai/docs/observability.rst
index 8068bdf7a4b..b1a6f4752db 100644
--- a/providers/common/ai/docs/observability.rst
+++ b/providers/common/ai/docs/observability.rst
@@ -49,9 +49,9 @@ How it works
   no GenAI spans are emitted.
 * **Correlation.** The worker opens a task span before the operator runs, so 
the
   agent's spans nest under it and share its ``trace_id``.
-  :class:`~airflow.providers.common.ai.operators.agent.AgentOperator` and
-  :class:`~airflow.providers.common.ai.operators.llm.LLMOperator` (and their
-  ``@task.agent`` / ``@task.llm`` decorator forms) additionally stamp the
+  :class:`~airflow.providers.common.ai.operators.agent.AgentOperator`,
+  :class:`~airflow.providers.common.ai.operators.llm.LLMOperator` and its SQL 
/ branch /
+  file-analysis / schema-compare subclasses (and their decorator forms) 
additionally stamp the
   task-instance identity on every GenAI span they emit: the five keys core
   tracing already puts on the task span (``airflow.dag_id``, 
``airflow.task_id``, ``airflow.dag_run.run_id``,
   ``airflow.task_instance.try_number``, ``airflow.task_instance.map_index``) 
plus
@@ -63,8 +63,8 @@ How it works
   attempts share one trace and appear as repeated task-run spans on it,
   distinguished by ``try number``. Only a manual clear or rerun regenerates the
   context and starts a new trace.
-* **Run join key.** For an ``AgentOperator`` or ``LLMOperator`` run (and their
-  ``@task.agent`` / ``@task.llm`` decorator equivalents), the task-instance id
+* **Run join key.** For an ``AgentOperator`` run or a run of ``LLMOperator`` 
or one of
+  its subclasses (and their decorator equivalents), the task-instance id
   (unique per attempt, since Airflow regenerates it on each retry) is passed to
   pydantic-ai as the run's ``run_id``. It surfaces on the run's GenAI spans as
   ``gen_ai.agent.call.id``, and the operator also exposes it, alongside the 
run's
@@ -92,31 +92,26 @@ How it works
   ``usage`` above, so a downstream task, a Jinja template, or a REST client 
can read which
   model responded without parsing the decision record or a span
   (``ti.xcom_pull(task_ids="my_agent", 
key="__AIRFLOW__COMMON_AI_MODEL_NAME__")``).
-  Only ``AgentOperator`` /
-  ``@task.agent`` and ``LLMOperator`` / ``@task.llm`` push it today; like 
``run_id`` and
-  ``usage``, with ``enable_hitl_review`` it is the initial run's model, not a
-  human-feedback regeneration's, since ``regenerate_with_feedback`` doesn't 
re-push it.
-  ``LLMOperator``'s subclasses that override ``execute()`` instead of calling
-  ``super().execute()`` -- ``LLMBranchOperator``, ``LLMSQLQueryOperator``,
-  ``LLMSchemaCompareOperator``, ``LLMFileAnalysisOperator`` -- don't publish 
it yet;
-  this is upcoming work.
-* **Scope.** The ``run_id`` / ``usage`` XComs, the model name, and the 
``airflow.*``
-  identity attributes come from ``AgentOperator`` / ``@task.agent`` and 
``LLMOperator`` /
-  ``@task.llm``, apart from a Strands, ADK or OpenAI Agents SDK agent run 
inside
-  ``agent_framework_tracing`` (see below). The other LLM operators still emit 
GenAI spans
-  correlated to the task span by nesting, but without the identity attributes, 
the run
-  join key, the model name, or a ``usage`` XCom.
+  ``AgentOperator`` and ``LLMOperator`` and its subclasses (and their 
decorator forms)
+  push it; like ``run_id`` and ``usage``, with ``enable_hitl_review`` it is 
the initial
+  run's model, not a human-feedback regeneration's, since 
``regenerate_with_feedback``
+  doesn't re-push it.
+* **Scope.** The ``run_id`` / ``usage`` XComs and the model name come from
+  ``AgentOperator`` and ``LLMOperator`` and its subclasses (and their 
decorator forms).
+  Those operators also stamp the ``airflow.*`` identity attributes on their 
spans, and so
+  does a Strands, ADK or OpenAI Agents SDK agent run inside 
``agent_framework_tracing``
+  (see below).
 * .. _howto/observability:model-tab:
 
   **Model tab.** Separately from tracing, the provider adds an "AI Model" tab 
to the
   task-instance page showing the resolved model name and the ``usage`` XCom 
above as a
   readable table, including an estimated cost. It needs Airflow >= 3.4, since 
it relies on
   the ``applies_to`` operator scoping core added in that version to show up 
only for
-  ``LLMOperator`` / ``AgentOperator`` runs (and their ``@task.llm`` / 
``@task.agent``
-  decorator forms) -- the match is on the exact operator name, so a subclass 
of either does
-  not get the tab automatically. Like the span-level cost above, the tab's 
number is a
-  best-effort ``genai-prices`` estimate, not provider-reported billing. With a 
custom XCom
-  backend that offloads large values to external storage (e.g. ``common.io`` 
with
+  ``AgentOperator`` runs and runs of ``LLMOperator`` and its SQL / branch / 
file-analysis /
+  schema-compare subclasses (and their decorator forms) -- the match is on the 
exact
+  operator name, so a subclass of your own does not get the tab automatically. 
Like the
+  span-level cost above, the tab's number is a best-effort ``genai-prices`` 
estimate, not
+  provider-reported billing. With a custom XCom backend that offloads large 
values to external storage (e.g. ``common.io`` with
   ``xcom_objectstorage_threshold`` set low), the tab reads the stored 
reference rather than
   the real value, since the public XCom endpoint it calls never runs the 
backend's
   ``deserialize_value``: the model name badge would show the reference string 
(e.g. an
diff --git a/providers/common/ai/docs/operators/llm_branch.rst 
b/providers/common/ai/docs/operators/llm_branch.rst
index b0c260db5be..b7405200037 100644
--- a/providers/common/ai/docs/operators/llm_branch.rst
+++ b/providers/common/ai/docs/operators/llm_branch.rst
@@ -231,7 +231,8 @@ The decision record
 ^^^^^^^^^^^^^^^^^^^
 
 Whether or not a bar is set, the operator pushes a ``decision`` XCom next to
-its return value (suppressed by ``do_xcom_push=False`` like any other):
+its return value and the run XComs listed under `Logging`_ (suppressed by
+``do_xcom_push=False`` like any other):
 
 .. code-block:: json
 
@@ -350,3 +351,6 @@ Logging
 After each LLM call, the operator logs a summary with model name, token usage,
 and request count at INFO level. See :ref:`AgentOperator logging 
<howto/operator:agent>`
 for details on the log format.
+
+Like :doc:`llm`, the operator also pushes ``usage``, ``run_id`` and the model 
name to
+XCom, on a failed run too. See :doc:`../observability`.
diff --git a/providers/common/ai/docs/operators/llm_file_analysis.rst 
b/providers/common/ai/docs/operators/llm_file_analysis.rst
index 1a666b16893..e4efec17dae 100644
--- a/providers/common/ai/docs/operators/llm_file_analysis.rst
+++ b/providers/common/ai/docs/operators/llm_file_analysis.rst
@@ -196,3 +196,13 @@ Parquet and Avro readers require their corresponding 
optional extras:
 
     pip install "apache-airflow-providers-common-ai[parquet]"
     pip install "apache-airflow-providers-common-ai[avro]"
+
+Logging
+-------
+
+After each LLM call, the operator logs a summary with model name, token usage,
+and request count at INFO level. See :ref:`AgentOperator logging 
<howto/operator:agent>`
+for details on the log format.
+
+Like :doc:`llm`, the operator also pushes ``usage``, ``run_id`` and the model 
name to
+XCom, on a failed run too. See :doc:`../observability`.
diff --git a/providers/common/ai/docs/operators/llm_schema_compare.rst 
b/providers/common/ai/docs/operators/llm_schema_compare.rst
index c7a2e729d95..d7e821acb3a 100644
--- a/providers/common/ai/docs/operators/llm_schema_compare.rst
+++ b/providers/common/ai/docs/operators/llm_schema_compare.rst
@@ -227,3 +227,6 @@ Logging
 After each LLM call, the operator logs a summary with model name, token usage,
 and request count at INFO level. See :ref:`AgentOperator logging 
<howto/operator:agent>`
 for details on the log format.
+
+Like :doc:`llm`, the operator also pushes ``usage``, ``run_id`` and the model 
name to
+XCom, on a failed run too. See :doc:`../observability`.
diff --git a/providers/common/ai/docs/operators/llm_sql.rst 
b/providers/common/ai/docs/operators/llm_sql.rst
index 5f457fb016b..89a9d9bc987 100644
--- a/providers/common/ai/docs/operators/llm_sql.rst
+++ b/providers/common/ai/docs/operators/llm_sql.rst
@@ -185,3 +185,6 @@ After each LLM call, the operator logs a summary with model 
name, token usage,
 and request count at INFO level. At DEBUG level, the generated SQL is also
 logged (truncated to 500 characters). See :ref:`AgentOperator logging 
<howto/operator:agent>`
 for details on the log format.
+
+Like :doc:`llm`, the operator also pushes ``usage``, ``run_id`` and the model 
name to
+XCom, on a failed run too. See :doc:`../observability`.
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 3ad36fffdb9..2cc5c822e06 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
@@ -368,45 +368,10 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator, 
LLMApprovalMixin, Usag
 
         agent = self._create_agent(output_type=self.output_type, 
instructions=self.system_prompt)
 
-        # A per-attempt key (the task-instance id on Airflow 3, which is 
regenerated on each
-        # retry; dag/run/task/map/try on Airflow 2) ties this run to its GenAI 
trace, the same
-        # join key AgentOperator uses. A hand-built context with no task 
instance runs untracked.
-        ti = self._resolve_task_instance(context)
-        run_kwargs: dict[str, Any] = {}
-        if ti is not None:
-            stamp_identity_on_agent_spans(agent, 
build_run_identity_attributes(ti))
-            run_kwargs["run_id"] = make_task_instance_run_key(ti)
-
-        attempt_usage = RunUsage()
-        try:
-            result = self._run_llm(
-                context, agent, self.prompt, usage_limits=usage_limits, 
run_usage=attempt_usage, **run_kwargs
-            )
-        except BaseException:
-            # A run that raises partway through (e.g. a cost_limit-triggered 
UsageLimitExceeded)
-            # still reports what it spent: the capped run is the one cost 
tracking most needs.
-            try:
-                log_run_usage(self.log, attempt_usage, outcome="failed")
-            except Exception:
-                self.log.warning("Failed to log partial usage for the failed 
run", exc_info=True)
-            try:
-                self._push_xcom(context, "usage", 
format_usage_for_xcom(attempt_usage))
-            except Exception:
-                self.log.warning("Failed to push usage XCom for the failed 
run", exc_info=True)
-            if ti is not None:
-                try:
-                    self._push_xcom(context, "run_id", run_kwargs["run_id"])
-                except Exception:
-                    self.log.warning("Failed to push run_id XCom for the 
failed run", exc_info=True)
-            raise
+        result = self._run_llm(context, agent, self.prompt, 
usage_limits=usage_limits)
         output = result.output
 
-        self._push_xcom(context, "usage", format_usage_for_xcom(attempt_usage))
-        if ti is not None:
-            self._push_xcom(context, "run_id", result.run_id)
         model_confidence = ModelConfidence.from_result(result)
-        if model_confidence.model is not None:
-            self._push_xcom(context, MODEL_NAME_XCOM_KEY, 
model_confidence.model)
         # Gated by the least confident of the fields that reported a 
confidence; a field whose type
         # reports none is not gated. A bare output type is the one field 
``response``.
         fields = list(model_confidence.confidence) or ["response"]
@@ -499,13 +464,67 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator, 
LLMApprovalMixin, Usag
         user_prompt: Any,
         *,
         usage_limits: UsageLimits | None,
-        run_usage: RunUsage | None = None,
+    ) -> AgentRunResult[Any]:
+        """
+        Run the agent and publish the run's ``usage``, ``run_id`` and model 
name on XCom.
+
+        Every ``execute`` of this operator and its subclasses runs the agent 
through here, so they
+        all report the same run metadata.
+        """
+        # A per-attempt key (the task-instance id on Airflow 3, which is 
regenerated on each
+        # retry; dag/run/task/map/try on Airflow 2) ties this run to its GenAI 
trace, the same
+        # join key AgentOperator uses. A hand-built context with no task 
instance runs untracked.
+        ti = self._resolve_task_instance(context)
+        run_kwargs: dict[str, Any] = {}
+        if ti is not None:
+            stamp_identity_on_agent_spans(agent, 
build_run_identity_attributes(ti))
+            run_kwargs["run_id"] = make_task_instance_run_key(ti)
+
+        attempt_usage = RunUsage()
+        try:
+            result = self._run_agent_within_budget(
+                context, agent, user_prompt, usage_limits=usage_limits, 
run_usage=attempt_usage, **run_kwargs
+            )
+        except BaseException:
+            # A run that raises partway through (e.g. a cost_limit-triggered 
UsageLimitExceeded)
+            # still reports what it spent: the capped run is the one cost 
tracking most needs.
+            try:
+                log_run_usage(self.log, attempt_usage, outcome="failed")
+            except Exception:
+                self.log.warning("Failed to log partial usage for the failed 
run", exc_info=True)
+            try:
+                self._push_xcom(context, "usage", 
format_usage_for_xcom(attempt_usage))
+            except Exception:
+                self.log.warning("Failed to push usage XCom for the failed 
run", exc_info=True)
+            if ti is not None:
+                try:
+                    self._push_xcom(context, "run_id", run_kwargs["run_id"])
+                except Exception:
+                    self.log.warning("Failed to push run_id XCom for the 
failed run", exc_info=True)
+            raise
+
+        self._push_xcom(context, "usage", format_usage_for_xcom(attempt_usage))
+        if ti is not None:
+            self._push_xcom(context, "run_id", result.run_id)
+        model_name = ModelConfidence.from_result(result).model
+        if model_name is not None:
+            self._push_xcom(context, MODEL_NAME_XCOM_KEY, model_name)
+        return result
+
+    def _run_agent_within_budget(
+        self,
+        context: Context,
+        agent: Agent[Any, Any],
+        user_prompt: Any,
+        *,
+        usage_limits: UsageLimits | None,
+        run_usage: RunUsage,
         **run_kwargs: Any,
     ) -> AgentRunResult[Any]:
         """
         Run the agent, bounding ``usage_limits`` across task retries when the 
usage budget applies.
 
-        ``run_usage``, when given, receives this attempt's own usage, also 
when the run raises
+        ``run_usage`` receives this attempt's own usage, also when the run 
raises
         partway through. With the usage budget, pydantic-ai is handed the 
cross-attempt total
         instead, so the attempt's share is credited to ``run_usage`` once the 
run ends.
         """
@@ -513,8 +532,7 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator, 
LLMApprovalMixin, Usag
         ti = context["task_instance"] if usage_limits is not None else None
         self._usage_budget = self._build_usage_budget(context, usage_limits, 
ti=ti)
         if self._usage_budget is None:
-            if run_usage is not None:
-                run_kwargs["usage"] = run_usage
+            run_kwargs["usage"] = run_usage
             result = self.run_agent_sync(agent, user_prompt, 
usage_limits=usage_limits, **run_kwargs)
             log_run_summary(self.log, result)
             return result
@@ -525,8 +543,7 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator, 
LLMApprovalMixin, Usag
                 agent, user_prompt, run_usage=cumulative_usage, 
usage_limits=usage_limits, **run_kwargs
             )
         finally:
-            if run_usage is not None:
-                run_usage.incr(subtract_run_usage(cumulative_usage, base))
+            run_usage.incr(subtract_run_usage(cumulative_usage, base))
         log_run_summary(self.log, result, usage=attempt_usage)
         self._log_cumulative_usage(cumulative_usage)
         return result
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/plugins/model_panel.py 
b/providers/common/ai/src/airflow/providers/common/ai/plugins/model_panel.py
index f3a4295cbf7..91b594db37e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/plugins/model_panel.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/model_panel.py
@@ -80,16 +80,24 @@ class ModelPanelPlugin(AirflowPlugin):
                 "bundle_url": get_bundle_url(_PLUGIN_PREFIX, "model.umd.cjs"),
                 "destination": "task_instance",
                 "url_route": "ai-model",
-                # Only LLMOperator and AgentOperator (and their 
@task.llm/@task.agent
-                # TaskFlow forms) push model_name/usage; other operators would 
show
-                # a permanently-empty tab. `operator_name` alone covers both 
the raw
+                # Only AgentOperator, LLMOperator and its subclasses (and 
their TaskFlow
+                # forms) push model_name/usage; other operators would show a
+                # permanently-empty tab. `operator_name` alone covers both the 
raw
                 # class name and the decorator's display name, since 
TaskInstance falls
                 # back to the class name when there's no custom_operator_name.
                 "applies_to": {
                     "operator_name": [
                         "LLMOperator",
+                        "LLMBranchOperator",
+                        "LLMSQLQueryOperator",
+                        "LLMSchemaCompareOperator",
+                        "LLMFileAnalysisOperator",
                         "AgentOperator",
                         "@task.llm",
+                        "@task.llm_branch",
+                        "@task.llm_sql",
+                        "@task.llm_schema_compare",
+                        "@task.llm_file_analysis",
                         "@task.agent",
                     ],
                 },
diff --git a/providers/common/ai/tests/unit/common/ai/conftest.py 
b/providers/common/ai/tests/unit/common/ai/conftest.py
index c002170da34..e99710eea19 100644
--- a/providers/common/ai/tests/unit/common/ai/conftest.py
+++ b/providers/common/ai/tests/unit/common/ai/conftest.py
@@ -20,8 +20,10 @@ from unittest.mock import MagicMock
 from uuid import uuid4
 
 import pytest
+from pydantic_ai.messages import ModelResponse
 from pydantic_ai.usage import RunUsage
 
+from airflow.providers.common.ai.utils.logging import MODEL_NAME_XCOM_KEY
 from airflow.providers.common.ai.utils.usage_budget import 
TaskStateStoreUsageBudget
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
@@ -104,6 +106,61 @@ def make_mock_run_result():
     return _make
 
 
[email protected]
+def reporting_task_instance():
+    """A task instance with id ``ti-1``, specced to what a run reads off it or 
pushes to."""
+    return MagicMock(
+        spec=["id", "dag_id", "task_id", "run_id", "try_number", "map_index", 
"xcom_push"],
+        id="ti-1",
+        dag_id="dag",
+        task_id="test",
+        run_id="run",
+        try_number=1,
+        map_index=-1,
+    )
+
+
[email protected]
+def make_reporting_agent(make_mock_run_result):
+    """Factory fixture creating a mock agent whose run spends a known usage, 
as ``(agent, expected_pushes)``.
+
+    ``expected_pushes`` maps each XCom key the run must report (``usage``, 
``run_id``, and the
+    model name unless ``model_name`` is ``None``) to its value, for a task 
instance with id ``ti-1``.
+    """
+
+    def _make(output, *, model_name):
+        result = make_mock_run_result(output)
+        result.response = ModelResponse(parts=[], model_name=model_name)
+
+        def _run_sync(*args, **kwargs):
+            # A real run updates the ``usage=`` object it is called with, 
which is what gets pushed,
+            # and reports back the ``run_id=`` it is called with.
+            kwargs["usage"].incr(RunUsage(requests=1, input_tokens=10, 
output_tokens=5))
+            result.run_id = kwargs["run_id"]
+            return result
+
+        agent = MagicMock(spec=["run_sync", "instrument"])
+        agent.run_sync.side_effect = _run_sync
+        expected_pushes = {
+            "usage": {
+                "requests": 1,
+                "input_tokens": 10,
+                "output_tokens": 5,
+                "total_tokens": 15,
+                "tool_calls": 0,
+                "cache_read_tokens": 0,
+                "cache_write_tokens": 0,
+                "cost": None,
+            },
+            "run_id": "ti-1",
+        }
+        if model_name is not None:
+            expected_pushes[MODEL_NAME_XCOM_KEY] = model_name
+        return agent, expected_pushes
+
+    return _make
+
+
 @pytest.fixture
 def task_state_store_accessor():
     """A ``MagicMock(spec=TaskStateStoreAccessor)`` backed by a plain dict.
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 09f2dda4ccb..747bbd46717 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
@@ -36,7 +36,7 @@ class TestLLMBranchDecoratedOperator:
         """The callable's return value becomes the LLM prompt, LLM output goes 
through do_branch."""
         downstream_enum = Enum("DownstreamTasks", {"positive": "positive", 
"negative": "negative"})
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
make_mock_run_result(downstream_enum.positive)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_do_branch.return_value = "positive"
@@ -56,7 +56,7 @@ class TestLLMBranchDecoratedOperator:
         assert result == "positive"
         assert op.prompt == "Route this review"
         mock_agent.run_sync.assert_called_once_with(
-            "Route this review", usage_limits=None, cancellation_token=ANY
+            "Route this review", usage=ANY, usage_limits=None, 
cancellation_token=ANY
         )
         mock_do_branch.assert_called_once()
 
@@ -81,7 +81,7 @@ class TestLLMBranchDecoratedOperator:
         """A non-empty Sequence[UserContent] return value is forwarded to 
run_sync as-is."""
         downstream_enum = Enum("DownstreamTasks", {"positive": "positive"})
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
make_mock_run_result(downstream_enum.positive)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_do_branch.return_value = "positive"
@@ -101,7 +101,9 @@ class TestLLMBranchDecoratedOperator:
         op.execute(context={})
 
         assert op.prompt == prompt
-        mock_agent.run_sync.assert_called_once_with(prompt, usage_limits=None, 
cancellation_token=ANY)
+        mock_agent.run_sync.assert_called_once_with(
+            prompt, usage=ANY, usage_limits=None, cancellation_token=ANY
+        )
 
     @patch.object(LLMBranchOperator, "do_branch")
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
@@ -111,7 +113,7 @@ class TestLLMBranchDecoratedOperator:
         """op_kwargs are resolved by the callable to build the prompt."""
         downstream_enum = Enum("DownstreamTasks", {"task_a": "task_a"})
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
make_mock_run_result(downstream_enum.task_a)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_file_analysis.py 
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_file_analysis.py
index aa45df1458b..5903138f817 100644
--- 
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_file_analysis.py
+++ 
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_file_analysis.py
@@ -58,7 +58,7 @@ class TestLLMFileAnalysisDecoratedOperator:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("This is a 
summary.")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -76,7 +76,7 @@ class TestLLMFileAnalysisDecoratedOperator:
         assert result == "This is a summary."
         assert op.prompt == "Summarize this text"
         mock_agent.run_sync.assert_called_once_with(
-            "prepared prompt", usage_limits=None, cancellation_token=ANY
+            "prepared prompt", usage=ANY, usage_limits=None, 
cancellation_token=ANY
         )
 
     @pytest.mark.parametrize(
@@ -104,7 +104,7 @@ class TestLLMFileAnalysisDecoratedOperator:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("done")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -122,5 +122,5 @@ class TestLLMFileAnalysisDecoratedOperator:
 
         assert op.prompt == "Summarize system logs"
         mock_agent.run_sync.assert_called_once_with(
-            "prepared prompt", usage_limits=None, cancellation_token=ANY
+            "prepared prompt", usage=ANY, usage_limits=None, 
cancellation_token=ANY
         )
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 e6004561167..73adba3f2d3 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
@@ -38,7 +38,7 @@ def _make_compare_result():
 
 
 def _make_mock_agent(output: SchemaCompareResult, make_mock_run_result):
-    mock_agent = MagicMock(spec=["run_sync"])
+    mock_agent = MagicMock(spec=["run_sync", "instrument"])
     mock_agent.run_sync.return_value = make_mock_run_result(output)
     return mock_agent
 
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 ecee8aac631..aed2f559586 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
@@ -43,7 +43,7 @@ class TestLLMSQLDecoratedOperator:
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_calls_callable_and_uses_result_as_prompt(self, 
mock_hook_cls, make_mock_run_result):
         """The user's callable return value becomes the LLM prompt."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("SELECT 1")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -56,7 +56,7 @@ class TestLLMSQLDecoratedOperator:
         assert result == "SELECT 1"
         assert op.prompt == "Get all users"
         mock_agent.run_sync.assert_called_once_with(
-            "Get all users", usage_limits=None, cancellation_token=ANY
+            "Get all users", usage=ANY, usage_limits=None, 
cancellation_token=ANY
         )
 
     @pytest.mark.parametrize(
@@ -77,7 +77,7 @@ class TestLLMSQLDecoratedOperator:
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_accepts_sequence_prompt(self, mock_hook_cls, 
make_mock_run_result):
         """A non-empty Sequence[UserContent] return value is forwarded to 
run_sync as-is."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("SELECT 1")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -91,12 +91,14 @@ class TestLLMSQLDecoratedOperator:
         op.execute(context={})
 
         assert op.prompt == prompt
-        mock_agent.run_sync.assert_called_once_with(prompt, usage_limits=None, 
cancellation_token=ANY)
+        mock_agent.run_sync.assert_called_once_with(
+            prompt, usage=ANY, usage_limits=None, cancellation_token=ANY
+        )
 
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_merges_op_kwargs_into_callable(self, mock_hook_cls, 
make_mock_run_result):
         """op_kwargs are resolved by the callable to build the prompt."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("SELECT 1")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
index e6f799b581c..baa07b6e18e 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
@@ -72,7 +72,7 @@ class TestLLMBranchOperator:
     def test_execute_builds_agent_with_model_settings_and_output_retries_only(
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_do_branch.return_value = "task_a"
@@ -97,7 +97,7 @@ class TestLLMBranchOperator:
     def test_execute_single_branch(self, mock_hook_cls, mock_do_branch, 
make_mock_run_result):
         """LLM returns a single enum member → do_branch receives a string."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_do_branch.return_value = "task_a"
@@ -115,9 +115,35 @@ class TestLLMBranchOperator:
         assert result == "task_a"
         mock_do_branch.assert_called_once_with(ctx, "task_a")
         mock_agent.run_sync.assert_called_once_with(
-            "Pick a branch", usage_limits=None, cancellation_token=ANY
+            "Pick a branch", usage=ANY, usage_limits=None, run_id=ANY, 
cancellation_token=ANY
         )
 
+    @pytest.mark.parametrize("model_name", ["gpt-5", None])
+    
@patch("airflow.providers.common.ai.operators.llm.stamp_identity_on_agent_spans",
 autospec=True)
+    @patch.object(LLMBranchOperator, "do_branch")
+    @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
+    def test_run_usage_run_id_and_model_name_pushed_to_xcom(
+        self, mock_hook_cls, mock_do_branch, mock_stamp, make_reporting_agent, 
model_name
+    ):
+        mock_agent, expected_pushes = make_reporting_agent("task_a", 
model_name=model_name)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+        op = LLMBranchOperator(task_id="test", prompt="Pick a branch", 
llm_conn_id="my_llm")
+        op.downstream_task_ids = {"task_a", "task_b"}
+        context = _make_context(ti_id="ti-1")
+
+        op.execute(context)
+
+        pushes = {
+            c.kwargs["key"]: c.kwargs["value"]
+            for c in context["task_instance"].xcom_push.call_args_list
+            if c.kwargs["key"] != "decision"
+        }
+        assert pushes == expected_pushes
+        mock_stamp.assert_called_once()
+        agent_arg, attrs = mock_stamp.call_args.args
+        assert agent_arg is mock_agent
+        assert attrs["airflow.task_instance.id"] == "ti-1"
+
     @patch.object(LLMBranchOperator, "do_branch")
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_coerces_usage_limits_dict_before_run_sync(
@@ -125,7 +151,7 @@ class TestLLMBranchOperator:
     ):
         """A dict ``usage_limits`` is coerced into a real ``UsageLimits`` 
before ``run_sync``."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_do_branch.return_value = "task_a"
@@ -148,7 +174,7 @@ class TestLLMBranchOperator:
     def test_execute_multi_branch(self, mock_hook_cls, mock_do_branch, 
make_mock_run_result):
         """allow_multiple_branches=True → LLM returns list of enums → 
do_branch receives list."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result(["task_a", 
"task_c"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_do_branch.return_value = ["task_a", "task_c"]
@@ -173,7 +199,7 @@ class TestLLMBranchOperator:
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
         """LLM returning an empty list fails instead of skipping every 
downstream task."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result([])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -194,7 +220,7 @@ class TestLLMBranchOperator:
     def test_system_prompt_forwarded(self, mock_hook_cls, mock_do_branch, 
make_mock_run_result):
         """system_prompt is passed to create_agent(instructions=...)."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -216,7 +242,7 @@ class TestLLMBranchOperator:
     def test_downstream_task_ids_used_for_enum(self, mock_hook_cls, 
mock_do_branch, make_mock_run_result):
         """The dynamic enum is built from self.downstream_task_ids."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("billing")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -245,7 +271,7 @@ class TestLLMBranchOperator:
         unlucky set order; without ``sorted()`` the enum comes out reversed 
and this fails.
         """
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -268,7 +294,7 @@ class TestLLMBranchOperator:
         a per-option description from: ``anyOf`` of ``{const, description}``, 
not a bare ``enum``.
         pydantic-ai's ``Choices`` builds it; a bare string and a 
``BranchOption`` describe alike.
         """
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("handle_auth")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -399,7 +425,7 @@ class TestLLMBranchOperator:
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
         """Without descriptions the schema is the bare enum it always was."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         op = LLMBranchOperator(task_id="route", prompt="Pick", 
llm_conn_id="my_llm")
@@ -441,7 +467,7 @@ class TestLLMBranchOperator:
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
         """On pydantic-ai without ``Choices`` the Enum fallback carries the 
same anyOf and the pick unwraps."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = LLMBranchOperator(
             task_id="route",
@@ -578,7 +604,7 @@ class TestLLMBranchOperatorUsageBudget:
         usage_budget_context,
     ):
         seed_usage_budget(task_state_store_accessor, requests=1)
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         key_present_in_do_branch = []
@@ -677,7 +703,7 @@ class TestLLMBranchOperatorConfidenceGate:
     def test_confident_pick_branches_and_records_the_decision(
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(
             make_mock_run_result,
             "rerun",
@@ -718,7 +744,7 @@ class TestLLMBranchOperatorConfidenceGate:
     def test_pick_below_the_bar_goes_to_review_before_branching(
         self, mock_hook_cls, mock_do_branch, mock_upsert, mock_trigger_cls, 
make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(
             make_mock_run_result,
             "page_oncall",
@@ -763,7 +789,7 @@ class TestLLMBranchOperatorConfidenceGate:
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
         """0.52 is under page_oncall's 0.9 bar but rerun's bar is 0.6, so a 
rerun at 0.65 branches."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", confidence=0.65)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(
@@ -791,7 +817,7 @@ class TestLLMBranchOperatorConfidenceGate:
         self, mock_hook_cls, mock_do_branch, mock_upsert, mock_trigger_cls, 
make_mock_run_result
     ):
         """A branch the overrides do not name is still gated, by 
min_confidence."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"ignore", confidence=0.3)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(
@@ -820,7 +846,7 @@ class TestLLMBranchOperatorConfidenceGate:
         self, mock_hook_cls, mock_do_branch, mock_upsert, mock_trigger_cls, 
make_mock_run_result
     ):
         """A model that reports no confidence must not silently bypass a 
configured bar."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", model="claude-sonnet-5")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(decision_policy=DecisionPolicy(min_confidence=0.7))
@@ -840,7 +866,7 @@ class TestLLMBranchOperatorConfidenceGate:
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_low_confidence_can_fail_the_task(self, mock_hook_cls, 
mock_do_branch, make_mock_run_result):
         """on_uncertain="fail": the record is written first so the failure is 
explained."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", confidence=0.4)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(decision_policy=DecisionPolicy(min_confidence=0.7, 
on_uncertain="fail"))
@@ -859,7 +885,7 @@ class TestLLMBranchOperatorConfidenceGate:
     @patch.object(LLMBranchOperator, "do_branch")
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_text_model_with_a_bar_can_fail(self, mock_hook_cls, 
mock_do_branch, make_mock_run_result):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", model="claude-sonnet-5")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(decision_policy=DecisionPolicy(min_confidence=0.7, 
on_uncertain="fail"))
@@ -874,7 +900,7 @@ class TestLLMBranchOperatorConfidenceGate:
     def test_no_bar_and_no_confidence_is_todays_behaviour(
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", model="claude-sonnet-5")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op()
@@ -905,7 +931,7 @@ class TestLLMBranchOperatorConfidenceGate:
     def test_do_xcom_push_false_suppresses_the_record(
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", confidence=0.9)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(decision_policy=DecisionPolicy(min_confidence=0.7), 
do_xcom_push=False)
@@ -923,7 +949,7 @@ class TestLLMBranchOperatorConfidenceGate:
     def test_multiple_branches_use_the_strictest_picked_bar(
         self, mock_hook_cls, mock_do_branch, make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(
             make_mock_run_result, ["rerun", "page_oncall"], confidence=0.95
         )
@@ -953,7 +979,7 @@ class TestLLMBranchOperatorConfidenceGate:
     def test_require_approval_still_asks_at_high_confidence(
         self, mock_hook_cls, mock_do_branch, mock_upsert, mock_trigger_cls, 
make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _jev_result(make_mock_run_result, 
"rerun", confidence=0.99)
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = self._op(require_approval=True, 
decision_policy=DecisionPolicy(min_confidence=0.5))
@@ -1089,7 +1115,7 @@ class TestLLMBranchOperatorApproval:
     ):
         """When require_approval=True, execute() pauses after the LLM choice, 
before do_branch."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -1118,7 +1144,7 @@ class TestLLMBranchOperatorApproval:
     ):
         """With allow_multiple_branches=True the choice is deferred as a JSON 
list."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result(["task_a", 
"task_c"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -1146,7 +1172,7 @@ class TestLLMBranchOperatorApproval:
     ):
         """The review body lists the valid branches and the editable param is 
an enum dropdown."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -1178,7 +1204,7 @@ class TestLLMBranchOperatorApproval:
     ):
         """With allow_multiple_branches the editable param is an array enum 
(multi-select)."""
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result(["task_a"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -1215,7 +1241,7 @@ class TestLLMBranchOperatorApproval:
     def test_execute_with_approval_reviews_a_sequence_prompt(
         self, mock_hook_cls, mock_upsert, mock_trigger_cls, 
make_mock_run_result
     ):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("task_a")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
index 74e2f8f2b59..61feeafa241 100644
--- 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
+++ 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
@@ -112,7 +112,7 @@ class TestLLMFileAnalysisOperator:
         mock_build_request.return_value = FileAnalysisRequest(
             user_content="prepared prompt", resolved_paths=["/tmp/app.log"], 
total_size_bytes=10
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("Analysis 
complete")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -141,7 +141,7 @@ class TestLLMFileAnalysisOperator:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("Analysis 
complete")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -166,9 +166,31 @@ class TestLLMFileAnalysisOperator:
             sample_rows=10,
         )
         mock_agent.run_sync.assert_called_once_with(
-            "prepared prompt", usage_limits=None, cancellation_token=ANY
+            "prepared prompt", usage=ANY, usage_limits=None, 
cancellation_token=ANY
         )
 
+    @pytest.mark.parametrize("model_name", ["gpt-5", None])
+    @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
+    @patch(
+        
"airflow.providers.common.ai.operators.llm_file_analysis.build_file_analysis_request",
 autospec=True
+    )
+    def test_run_usage_run_id_and_model_name_pushed_to_xcom(
+        self, mock_build_request, mock_hook_cls, make_reporting_agent, 
reporting_task_instance, model_name
+    ):
+        mock_build_request.return_value = FileAnalysisRequest(
+            user_content="prepared prompt", resolved_paths=["/tmp/app.log"], 
total_size_bytes=10
+        )
+        mock_agent, expected_pushes = make_reporting_agent("Analysis 
complete", model_name=model_name)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+        op = LLMFileAnalysisOperator(
+            task_id="test", prompt="Summarize the file", llm_conn_id="my_llm", 
file_path="/tmp/app.log"
+        )
+        ti = reporting_task_instance
+
+        op.execute(context={"task_instance": ti})
+
+        assert {c.kwargs["key"]: c.kwargs["value"] for c in 
ti.xcom_push.call_args_list} == expected_pushes
+
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     @patch(
         
"airflow.providers.common.ai.operators.llm_file_analysis.build_file_analysis_request",
 autospec=True
@@ -182,7 +204,7 @@ class TestLLMFileAnalysisOperator:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("Analysis 
complete")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -209,7 +231,7 @@ class TestLLMFileAnalysisOperator:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
_make_mock_run_result(Summary(findings=["error spike"]))
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -236,7 +258,7 @@ class TestLLMFileAnalysisOperator:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
_make_mock_run_result(Summary(findings=["error spike"]))
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -335,7 +357,7 @@ class TestLLMFileAnalysisOperatorUsageBudget:
         mock_build_request.return_value = FileAnalysisRequest(
             user_content="prepared prompt", resolved_paths=["/tmp/app.log"], 
total_size_bytes=10
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("analysis")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         op = LLMFileAnalysisOperator(
@@ -369,7 +391,7 @@ class TestLLMFileAnalysisOperatorApproval:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("LLM 
response")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -403,7 +425,7 @@ class TestLLMFileAnalysisOperatorApproval:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
_make_mock_run_result(Summary(findings=["error spike"]))
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -479,7 +501,7 @@ class TestLLMFileAnalysisOperatorApproval:
             resolved_paths=["/tmp/app.log"],
             total_size_bytes=10,
         )
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = _make_mock_run_result("output")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
index fa9c7a08384..51be164cc14 100644
--- 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
+++ 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
@@ -296,10 +296,33 @@ class TestLLMSchemaCompareOperator:
             retries=None,
         )
         mock_agent.run_sync.assert_called_once_with(
-            "user_prompt", usage_limits=None, cancellation_token=mock.ANY
+            "user_prompt", usage=mock.ANY, usage_limits=None, 
cancellation_token=mock.ANY
         )
         assert result == {"compatible": True, "mismatches": [], "summary": 
"All good"}
 
+    @pytest.mark.parametrize("model_name", ["gpt-5", None])
+    @mock.patch.object(LLMSchemaCompareOperator, "_build_schema_context", 
return_value="schema_context")
+    def test_run_usage_run_id_and_model_name_pushed_to_xcom(
+        self, mock_build_schema_context, make_reporting_agent, 
reporting_task_instance, model_name
+    ):
+        mock_agent, expected_pushes = make_reporting_agent(
+            SchemaCompareResult(compatible=True, mismatches=[], summary="All 
good"), model_name=model_name
+        )
+        op = LLMSchemaCompareOperator(
+            task_id="test",
+            prompt="Compare",
+            llm_conn_id="llm_conn",
+            db_conn_ids=["a", "b"],
+            table_names=["t"],
+        )
+        op.llm_hook = MagicMock(spec=PydanticAIHook)
+        op.llm_hook.create_agent.return_value = mock_agent
+        ti = reporting_task_instance
+
+        op.execute(context={"task_instance": ti})
+
+        assert {c.kwargs["key"]: c.kwargs["value"] for c in 
ti.xcom_push.call_args_list} == expected_pushes
+
     @mock.patch(
         
"airflow.providers.common.ai.operators.llm_schema_compare.LLMSchemaCompareOperator._build_schema_context"
     )
@@ -415,6 +438,7 @@ class TestLLMSchemaCompareOperator:
 
         mock_agent.run_sync.assert_called_once_with(
             "Compare S3 Parquet schema against the Postgres table and flag 
breaking changes",
+            usage=mock.ANY,
             usage_limits=None,
             cancellation_token=mock.ANY,
         )
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
index cc3fd903ee8..8e56ceb6d5a 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
@@ -53,7 +53,7 @@ else:
 
 def _make_mock_agent(output: str, make_mock_run_result):
     """Create a mock agent that returns the given output string."""
-    mock_agent = MagicMock(spec=["run_sync"])
+    mock_agent = MagicMock(spec=["run_sync", "instrument"])
     mock_agent.run_sync.return_value = make_mock_run_result(output)
     return mock_agent
 
@@ -252,9 +252,23 @@ class TestLLMSQLQueryOperator:
 
         assert result == "SELECT id, name FROM users WHERE active = true"
         mock_agent.run_sync.assert_called_once_with(
-            "Get active users", usage_limits=None, cancellation_token=ANY
+            "Get active users", usage=ANY, usage_limits=None, run_id=ANY, 
cancellation_token=ANY
         )
 
+    @pytest.mark.parametrize("model_name", ["gpt-5", None])
+    @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
+    def test_run_usage_run_id_and_model_name_pushed_to_xcom(
+        self, mock_hook_cls, make_reporting_agent, reporting_task_instance, 
model_name
+    ):
+        mock_agent, expected_pushes = make_reporting_agent("SELECT 1", 
model_name=model_name)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+        op = LLMSQLQueryOperator(task_id="test", prompt="Get one", 
llm_conn_id="my_llm")
+        ti = reporting_task_instance
+
+        op.execute(context={"task_instance": ti})
+
+        assert {c.kwargs["key"]: c.kwargs["value"] for c in 
ti.xcom_push.call_args_list} == expected_pushes
+
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_coerces_usage_limits_dict_before_run_sync(
         self, mock_hook_cls, make_mock_run_result, usage_budget_context
diff --git 
a/providers/common/ai/tests/unit/common/ai/plugins/test_model_panel.py 
b/providers/common/ai/tests/unit/common/ai/plugins/test_model_panel.py
index 94c0363da14..9b339cec15d 100644
--- a/providers/common/ai/tests/unit/common/ai/plugins/test_model_panel.py
+++ b/providers/common/ai/tests/unit/common/ai/plugins/test_model_panel.py
@@ -17,6 +17,8 @@
 from __future__ import annotations
 
 import copy
+import importlib
+import pkgutil
 
 import pytest
 
@@ -27,8 +29,7 @@ if not AIRFLOW_V_3_1_PLUS:
     pytest.skip("The AI Model panel is only compatible with Airflow >= 3.1.0", 
allow_module_level=True)
 
 from airflow import plugins_manager
-from airflow.providers.common.ai.decorators.agent import 
_AgentDecoratedOperator
-from airflow.providers.common.ai.decorators.llm import _LLMDecoratedOperator
+from airflow.providers.common.ai import decorators, operators
 from airflow.providers.common.ai.operators.agent import AgentOperator
 from airflow.providers.common.ai.operators.llm import LLMOperator
 from airflow.providers.common.ai.plugins.model_panel import ModelPanelPlugin
@@ -38,6 +39,23 @@ requires_3_4 = pytest.mark.skipif(
 )
 
 
+def get_in_tree_model_operator_names() -> set[str]:
+    """Return the operator name a task instance records for each in-tree class 
that publishes the model name."""
+    for package in (operators, decorators):
+        for module in pkgutil.iter_modules(package.__path__):
+            importlib.import_module(f"{package.__name__}.{module.name}")
+
+    names = set()
+    pending: list[type] = [LLMOperator, AgentOperator]
+    while pending:
+        cls = pending.pop()
+        pending.extend(cls.__subclasses__())
+        # Subclasses defined by other test modules are not shipped, so only 
the provider's own count.
+        if cls.__module__.startswith("airflow.providers.common.ai."):
+            names.add(getattr(cls, "custom_operator_name", None) or 
cls.__name__)
+    return names
+
+
 class TestModelPanelPlugin:
     def test_plugin_name(self):
         assert ModelPanelPlugin.name == "ai_model_panel"
@@ -64,16 +82,10 @@ class TestModelPanelPlugin:
 
     @requires_3_4
     def test_applies_to_scopes_to_operators_that_publish_model_name(self):
-        """Tied to the actual operator/decorator names a task instance would 
record,
-        so a rename of either doesn't silently desync the tab's scoping from 
reality."""
+        """Built from the in-tree subclasses, so a new LLMOperator subclass or 
a rename can't silently
+        desync the tab's scoping from the operators that publish the model 
name."""
         app = ModelPanelPlugin.react_apps[0]
-        operator_names = app["applies_to"]["operator_name"]
-        assert set(operator_names) == {
-            LLMOperator.__name__,
-            AgentOperator.__name__,
-            _LLMDecoratedOperator.custom_operator_name,
-            _AgentDecoratedOperator.custom_operator_name,
-        }
+        assert set(app["applies_to"]["operator_name"]) == 
get_in_tree_model_operator_names()
 
     @requires_3_4
     def test_react_app_survives_core_applies_to_validation(self):

Reply via email to