Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ class TestGetPlugins:
# Filters
(
{},
19,
20,
[
"InformaticaProviderPlugin",
"MetadataCollectionPlugin",
"OpenLineageProviderPlugin",
"ai_model_panel",
"business_day_window_plugin",
"databricks_workflow",
"decreasing_priority_weight_strategy_plugin",
Expand All @@ -64,14 +65,14 @@ class TestGetPlugins:
),
(
{"limit": 3, "offset": 3},
19,
20,
[
"ai_model_panel",
"business_day_window_plugin",
"databricks_workflow",
"decreasing_priority_weight_strategy_plugin",
],
),
({"limit": 1}, 19, ["InformaticaProviderPlugin"]),
({"limit": 1}, 20, ["InformaticaProviderPlugin"]),
],
)
def test_should_respond_200(
Expand Down Expand Up @@ -220,7 +221,7 @@ def test_invalid_external_view_destination_should_log_warning_and_continue(self,
assert len(plugins_page) == 8
assert "test_plugin_invalid" not in [p["name"] for p in plugins_page]

assert body["total_entries"] == 19
assert body["total_entries"] == 20


@skip_if_force_lowest_dependencies_marker
Expand Down
2 changes: 1 addition & 1 deletion airflow-core/tests/unit/plugins/test_plugins_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,7 @@ def test_does_not_double_import_entrypoint_provider_plugins(self):
# Mock/skip loading from plugin dir
with mock.patch("airflow.plugins_manager._load_plugins_from_plugin_directory", return_value=([], [])):
plugins = plugins_manager._get_plugins()[0]
assert len(plugins) == 7
assert len(plugins) == 8


class TestWindowPluginRegistration:
Expand Down
15 changes: 15 additions & 0 deletions providers/common/ai/docs/observability.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ How it works
``@task.llm`` additionally pushes the model name above. The other LLM
operators still emit GenAI spans correlated to the task span by nesting, but
without the identity attributes, the run join key, or the model name.
* **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
``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
``s3://...`` path) instead of the model, and ``usage`` would come back as that same
string rather than an object -- the tab treats a non-object ``usage`` as absent and
hides the stat boxes instead of rendering ``undefined`` in each one.
* **Content is off by default.** Only token counts, model id, latency, tool
names, and finish reason are recorded. Prompt and completion text is never
emitted unless you opt in (see below).
Expand Down
2 changes: 2 additions & 0 deletions providers/common/ai/provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ hooks:
plugins:
- name: hitl_review
plugin-class: airflow.providers.common.ai.plugins.hitl_review.HITLReviewPlugin
- name: ai_model_panel
plugin-class: airflow.providers.common.ai.plugins.model_panel.ModelPanelPlugin


config:
Expand Down
1 change: 1 addition & 0 deletions providers/common/ai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ provider_info = "airflow.providers.common.ai.get_provider_info:get_provider_info

[project.entry-points."airflow.plugins"]
hitl_review = "airflow.providers.common.ai.plugins.hitl_review:HITLReviewPlugin"
ai_model_panel = "airflow.providers.common.ai.plugins.model_panel:ModelPanelPlugin"

[tool.hatch.version]
path = "src/airflow/providers/common/ai/__init__.py"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ def get_provider_info():
{
"name": "hitl_review",
"plugin-class": "airflow.providers.common.ai.plugins.hitl_review.HITLReviewPlugin",
}
},
{
"name": "ai_model_panel",
"plugin-class": "airflow.providers.common.ai.plugins.model_panel.ModelPanelPlugin",
},
],
"config": {
"common.ai": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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.
"""URL helpers shared by this provider's React plugins (``hitl_review``, ``model_panel``)."""

from __future__ import annotations

from urllib.parse import urlparse

from airflow.providers.common.compat.sdk import conf


def get_base_url_path(path: str) -> str:
"""Construct URL path with webserver base_url prefix for non-root deployments."""
base_url = conf.get("api", "base_url", fallback="/")
if base_url.startswith(("http://", "https://")):
base_path = urlparse(base_url).path
else:
base_path = base_url
base_path = base_path.rstrip("/")
return base_path + path


def get_bundle_url(plugin_prefix: str, bundle_filename: str) -> str:
"""
Return the bundle URL for a plugin's React bundle.

Uses an absolute URL when api.base_url is a full URL so the bundle loads
correctly in Vite dev mode, where import() resolves relative to the script
origin (5173) rather than the document origin (28080).
"""
path = get_base_url_path(f"{plugin_prefix}/static/{bundle_filename}")
base_url = conf.get("api", "base_url", fallback="/")
if base_url.startswith(("http://", "https://")):
parsed = urlparse(base_url)
return f"{parsed.scheme}://{parsed.netloc}" + path
return path
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,18 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, Any
from urllib.parse import urlparse
from uuid import UUID

from airflow.plugins_manager import AirflowPlugin
from airflow.providers.common.compat.sdk import conf
from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_1_PLUS, get_base_airflow_version_tuple

AIRFLOW_V_3_4_PLUS = get_base_airflow_version_tuple() >= (3, 4, 0)
from airflow.providers.common.ai.plugins._plugin_urls import get_bundle_url
from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_1_PLUS, AIRFLOW_V_3_4_PLUS

if TYPE_CHECKING:
from airflow.plugins_manager import FastAPIAppDict, ReactAppDict

_PLUGIN_PREFIX = "/hitl-review"


def _get_base_url_path(path: str) -> str:
"""Construct URL path with webserver base_url prefix for non-root deployments."""
base_url = conf.get("api", "base_url", fallback="/")
if base_url.startswith(("http://", "https://")):
base_path = urlparse(base_url).path
else:
base_path = base_url
base_path = base_path.rstrip("/")
return base_path + path


def _get_bundle_url() -> str:
"""
Return bundle URL for the React plugin.

Uses an absolute URL when api.base_url is a full URL so the bundle loads
correctly in Vite dev mode, where import() resolves relative to the script
origin (5173) rather than the document origin (28080).
"""
path = _get_base_url_path(f"{_PLUGIN_PREFIX}/static/main.umd.cjs")
base_url = conf.get("api", "base_url", fallback="/")
if base_url.startswith(("http://", "https://")):
parsed = urlparse(base_url)
return f"{parsed.scheme}://{parsed.netloc}" + path
return path


if AIRFLOW_V_3_1_PLUS:
import mimetypes
from pathlib import Path
Expand Down Expand Up @@ -658,7 +628,7 @@ class HITLReviewPlugin(AirflowPlugin):
react_apps = [
{
"name": "HITL Review",
"bundle_url": _get_bundle_url(),
"bundle_url": get_bundle_url(_PLUGIN_PREFIX, "main.umd.cjs"),
"destination": "task_instance",
"url_route": "hitl-review",
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 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.

from __future__ import annotations

from typing import TYPE_CHECKING

from airflow.plugins_manager import AirflowPlugin
from airflow.providers.common.ai.plugins._plugin_urls import get_bundle_url
from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_1_PLUS, AIRFLOW_V_3_4_PLUS

if TYPE_CHECKING:
from airflow.plugins_manager import FastAPIAppDict, ReactAppDict

_PLUGIN_PREFIX = "/ai-model"


if AIRFLOW_V_3_1_PLUS:
import mimetypes
from pathlib import Path

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

# Ensure proper MIME type for the plugin bundle (FastAPI serves .cjs as text/plain by default).
mimetypes.add_type("application/javascript", ".cjs")

model_panel_app = FastAPI(
title="AI Model Panel",
description="Serves the static 'Model' tab bundle for task instances run with common.ai operators.",
)

_WWW_DIR = Path(__file__).parent / "www"
_dist_dir = _WWW_DIR / "dist"
if _dist_dir.is_dir():
model_panel_app.mount(
"/static",
StaticFiles(directory=str(_dist_dir.absolute()), html=True),
name="model_panel_static",
)


class ModelPanelPlugin(AirflowPlugin):
Comment thread
pierrejeambrun marked this conversation as resolved.
Resolved
"""Register the 'Model' tab showing the resolved LLM model name and token usage."""

name = "ai_model_panel"
fastapi_apps: list[FastAPIAppDict] = []
react_apps: list[ReactAppDict] = []
if AIRFLOW_V_3_1_PLUS:
fastapi_apps = [
{
"name": "ai-model-panel",
"app": model_panel_app,
"url_prefix": _PLUGIN_PREFIX,
}
]
if AIRFLOW_V_3_4_PLUS:
# `applies_to` (the per-operator scoping below) only came into core with
# apache/airflow#69148 (3.4.0). An older core silently accepts the extra key and
# ignores it, so the tab would show up on every task instance instead and land on
# the empty state for anything that isn't an LLM/Agent run -- the whole entry is
# withheld pre-3.4 rather than shipping that unscoped tab.
react_apps = [
{
"name": "AI Model",
"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
# 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": {
Comment thread
pierrejeambrun marked this conversation as resolved.
Resolved
"operator_names": [
"LLMOperator",
"AgentOperator",
"@task.llm",
"@task.agent",
],
},
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
],
"scripts": {
"dev": "vite --port 5174 --strictPort",
"build": "vite build",
"build": "vite build && vite build --mode model",
"build:types": "tsc --p tsconfig.lib.json",
"build:lib": "vite build",
"lint": "tsc --p tsconfig.app.json --noEmit",
Expand All @@ -33,6 +33,7 @@
"@emotion/react": "^11.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading