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

Lee-W pushed a commit to branch common-ai-supported-services-page
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 707d760e90167ac657c99739598299426016e724
Author: Wei Lee <[email protected]>
AuthorDate: Fri Sep 25 20:56:08 2026 +0900

    Keep the common.ai toolset services list specific to common.ai
    
    Toolsets are a common.ai concept rather than a provider-wide one, so a
    provider.yaml schema field and a globally registered Sphinx directive for
    them would invite other providers to adopt a shape that does not apply to
    them. The toolset table now lives with the common.ai docs and is guarded
    by the provider's own metadata tests; the connection-type services table
    keeps using the shared mechanism, since that field already exists for
    every provider.
---
 airflow-core/src/airflow/provider.yaml.schema.json | 24 -------
 .../23_provider_hook_migration_to_yaml.rst         | 27 --------
 .../src/sphinx_exts/providers_extensions.py        | 62 ------------------
 .../templates/provider_toolset_services.rst.jinja2 | 28 --------
 .../unit/sphinx_exts/test_providers_extensions.py  | 58 -----------------
 providers/common/ai/docs/model_providers.rst       |  3 +
 providers/common/ai/docs/supported_services.rst    | 30 ++++++++-
 providers/common/ai/provider.yaml                  | 31 ---------
 .../tests/unit/common/ai/test_provider_metadata.py | 75 +++++++++++++++++-----
 9 files changed, 89 insertions(+), 249 deletions(-)

diff --git a/airflow-core/src/airflow/provider.yaml.schema.json 
b/airflow-core/src/airflow/provider.yaml.schema.json
index a4215cf640c..4c4a199a882 100644
--- a/airflow-core/src/airflow/provider.yaml.schema.json
+++ b/airflow-core/src/airflow/provider.yaml.schema.json
@@ -388,30 +388,6 @@
                         "items": {
                             "type": "string"
                         }
-                    },
-                    "external-services": {
-                        "description": "Per-module list of external services 
each toolset reaches. Representative, non-exhaustive. Each 'module' must also 
appear in 'python-modules'.",
-                        "type": "array",
-                        "items": {
-                            "type": "object",
-                            "properties": {
-                                "module": {
-                                    "type": "string",
-                                    "description": "Dotted path of the toolset 
module, as listed in 'python-modules'."
-                                },
-                                "services": {
-                                    "type": "array",
-                                    "items": {
-                                        "type": "string"
-                                    }
-                                }
-                            },
-                            "additionalProperties": false,
-                            "required": [
-                                "module",
-                                "services"
-                            ]
-                        }
                     }
                 },
                 "additionalProperties": false,
diff --git a/contributing-docs/23_provider_hook_migration_to_yaml.rst 
b/contributing-docs/23_provider_hook_migration_to_yaml.rst
index b32aa2a5501..3b0069dbeb4 100644
--- a/contributing-docs/23_provider_hook_migration_to_yaml.rst
+++ b/contributing-docs/23_provider_hook_migration_to_yaml.rst
@@ -109,33 +109,6 @@ supported field options, see
           type: string
           default: "my-project"
 
-Toolset metadata is defined under the separate top-level ``toolsets`` key:
-
-toolsets.external-services
-~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Per-module list of external services each toolset reaches, added as a sibling 
of
-``python-modules`` inside a ``toolsets`` entry. Unlike the connection-type
-``external-services`` key above, this one is not surfaced on the registry's
-provider version page, and there is no built-in Sphinx rendering for it either 
--
-a provider that wants to show it must add its own docs page with the
-``provider-toolset-services`` directive, the way ``common.ai`` does in
-``providers/common/ai/docs/supported_services.rst`` (which also renders the
-connection-type list there via ``provider-connection-services``). As with the
-connection-type list, the entries are representative, not exhaustive. Every
-``module`` value must also appear in that entry's ``python-modules`` list.
-
-.. code-block:: yaml
-
-    toolsets:
-      - integration-name: Common AI
-        python-modules:
-          - airflow.providers.common.ai.toolsets.hook
-        external-services:
-          - module: airflow.providers.common.ai.toolsets.hook
-            services:
-              - Any Airflow connection, through its provider hook
-
 Migration Tool
 --------------
 
diff --git a/devel-common/src/sphinx_exts/providers_extensions.py 
b/devel-common/src/sphinx_exts/providers_extensions.py
index ca45c16981d..70be9ebbb59 100644
--- a/devel-common/src/sphinx_exts/providers_extensions.py
+++ b/devel-common/src/sphinx_exts/providers_extensions.py
@@ -534,57 +534,6 @@ def _render_connection_services_content(package_name: str) 
-> str:
     return _render_template("provider_connection_services.rst.jinja2", 
rows=rows)
 
 
-# Display name for each toolset module: the class name for modules that map to 
one
-# (`toolsets.rst` documents each under a `` ``ClassName`` `` heading), or a 
short
-# descriptive name for the two that don't (`managed_agent` documents a family 
of
-# provider-specific subclasses under "Managed Agent Toolsets"; 
`langchain_bridge` is a
-# function, documented under "Working with LangChain"). Kept here rather than 
in
-# `provider.yaml` because it is presentation-only, not metadata the registry 
also needs.
-#
-# Deliberately no `.get(basename, default)` fallback below: every module in
-# `python-modules` must have an entry here, the same way `LABELS` and the 
anchor check
-# in `test_provider_metadata.py` require full coverage. A missing entry for a 
new
-# toolset module fails loudly with a `ValueError` naming the provider and 
module, not
-# a silently rendered basename.
-_TOOLSET_DISPLAY_NAMES = {
-    "hook": "HookToolset",
-    "sql": "SQLToolset",
-    "datafusion": "DataFusionToolset",
-    "logging": "LoggingToolset",
-    "mcp": "MCPToolset",
-    "skills": "AgentSkillsToolset",
-    "sandbox": "SandboxToolset",
-    "langchain_bridge": "LangChain Bridge",
-    "managed_agent": "Managed Agent Toolsets",
-}
-
-
-def _render_toolset_services_content(package_name: str) -> str:
-    provider = _find_provider_package_data(package_name)
-    rows = []
-    for toolset in provider.get("toolsets", []):
-        services_by_module = {
-            entry["module"]: entry["services"] for entry in 
toolset.get("external-services") or []
-        }
-        for module in toolset.get("python-modules", []):
-            basename = module.rsplit(".", 1)[-1]
-            try:
-                display_name = _TOOLSET_DISPLAY_NAMES[basename]
-            except KeyError as e:
-                raise ValueError(
-                    f"No display name for toolset module '{module}' of 
provider "
-                    f"'{package_name}'. Add an entry for '{basename}' to 
`_TOOLSET_DISPLAY_NAMES`."
-                ) from e
-            rows.append(
-                {
-                    "display_name": display_name,
-                    "services": services_by_module.get(module, []),
-                    "ref": f"howto/toolset:{basename}",
-                }
-            )
-    return _render_template("provider_toolset_services.rst.jinja2", rows=rows)
-
-
 class ProviderConnectionServicesDirective(BaseJinjaReferenceDirective):
     """Render a table of a provider's connection types and the external 
services each reaches."""
 
@@ -595,20 +544,9 @@ class 
ProviderConnectionServicesDirective(BaseJinjaReferenceDirective):
         return _render_connection_services_content(self.arguments[0])
 
 
-class ProviderToolsetServicesDirective(BaseJinjaReferenceDirective):
-    """Render a table of a provider's toolset modules and the external 
services each reaches."""
-
-    required_arguments = 1
-    optional_arguments = 0
-
-    def render_content(self, *, tags: set[str] | None, header_separator: str = 
DEFAULT_HEADER_SEPARATOR):
-        return _render_toolset_services_content(self.arguments[0])
-
-
 def setup(app):
     """Setup plugin"""
     app.add_directive("airflow-providers-openlineage-supported-classes", 
OpenLineageSupportedClassesDirective)
     app.add_directive("provider-connection-services", 
ProviderConnectionServicesDirective)
-    app.add_directive("provider-toolset-services", 
ProviderToolsetServicesDirective)
 
     return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git 
a/devel-common/src/sphinx_exts/templates/provider_toolset_services.rst.jinja2 
b/devel-common/src/sphinx_exts/templates/provider_toolset_services.rst.jinja2
deleted file mode 100644
index bc9059cc8cf..00000000000
--- 
a/devel-common/src/sphinx_exts/templates/provider_toolset_services.rst.jinja2
+++ /dev/null
@@ -1,28 +0,0 @@
-{#
- 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.
-#}
-.. list-table::
-   :header-rows: 1
-   :widths: 30 70
-
-   * - Toolset
-     - External services
-   {% for row in rows -%}
-   * - :ref:`{{ row.display_name }} <{{ row.ref }}>`
-     - {{ row.services | join(', ') }}
-   {% endfor %}
diff --git a/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py 
b/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py
index 04ce64269e0..ac0e130a31d 100644
--- a/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py
+++ b/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py
@@ -22,7 +22,6 @@ import pytest
 
 from sphinx_exts.providers_extensions import (
     _render_connection_services_content,
-    _render_toolset_services_content,
 )
 
 FAKE_PACKAGE_DATA = [
@@ -36,25 +35,6 @@ FAKE_PACKAGE_DATA = [
                 "external-services": ["OpenAI", "Anthropic"],
             }
         ],
-        "toolsets": [
-            {
-                "integration-name": "Example",
-                "python-modules": [
-                    "airflow.providers.example.toolsets.hook",
-                    "airflow.providers.example.toolsets.mcp",
-                ],
-                "external-services": [
-                    {
-                        "module": "airflow.providers.example.toolsets.hook",
-                        "services": ["Any Airflow connection, through its 
provider hook"],
-                    },
-                    {
-                        "module": "airflow.providers.example.toolsets.mcp",
-                        "services": ["Any MCP server (user-supplied 
endpoint)"],
-                    },
-                ],
-            }
-        ],
     }
 ]
 
@@ -75,41 +55,3 @@ def 
test_render_connection_services_content_unknown_package_raises(mock_load_pac
 
     with pytest.raises(ValueError, match="No provider.yaml found"):
         
_render_connection_services_content("apache-airflow-providers-does-not-exist")
-
-
[email protected]("sphinx_exts.providers_extensions.load_package_data", 
autospec=True)
-def 
test_render_toolset_services_content_renders_one_row_per_module(mock_load_package_data):
-    mock_load_package_data.return_value = FAKE_PACKAGE_DATA
-
-    rendered = 
_render_toolset_services_content("apache-airflow-providers-example")
-
-    assert ":ref:`HookToolset <howto/toolset:hook>`" in rendered
-    assert "Any Airflow connection, through its provider hook" in rendered
-    assert ":ref:`MCPToolset <howto/toolset:mcp>`" in rendered
-    assert "Any MCP server (user-supplied endpoint)" in rendered
-
-
-FAKE_PACKAGE_DATA_UNKNOWN_TOOLSET_MODULE = [
-    {
-        "package-name": "apache-airflow-providers-example",
-        "toolsets": [
-            {
-                "integration-name": "Example",
-                "python-modules": [
-                    "airflow.providers.example.toolsets.unknown_toolset",
-                ],
-                "external-services": [],
-            }
-        ],
-    }
-]
-
-
[email protected]("sphinx_exts.providers_extensions.load_package_data", 
autospec=True)
-def 
test_render_toolset_services_content_unknown_module_raises(mock_load_package_data):
-    mock_load_package_data.return_value = 
FAKE_PACKAGE_DATA_UNKNOWN_TOOLSET_MODULE
-
-    with pytest.raises(ValueError, match="apache-airflow-providers-example") 
as exc_info:
-        _render_toolset_services_content("apache-airflow-providers-example")
-
-    assert "airflow.providers.example.toolsets.unknown_toolset" in 
str(exc_info.value)
diff --git a/providers/common/ai/docs/model_providers.rst 
b/providers/common/ai/docs/model_providers.rst
index 7e31a70b648..a659b8be43b 100644
--- a/providers/common/ai/docs/model_providers.rst
+++ b/providers/common/ai/docs/model_providers.rst
@@ -26,6 +26,9 @@ name picks the vendor. Switching vendors is a connection
 change, not a Dag change. Find your vendor below, install the extra, create 
the connection
 type shown, and set the model name with that prefix.
 
+For the exhaustive, generated-and-hand-written list of every service this 
provider
+reaches (connections and toolsets alike), see :doc:`supported_services`.
+
 .. list-table::
    :header-rows: 1
    :widths: 18 16 18 20 28
diff --git a/providers/common/ai/docs/supported_services.rst 
b/providers/common/ai/docs/supported_services.rst
index 8e51f8f64a5..ad0e9e5c4c2 100644
--- a/providers/common/ai/docs/supported_services.rst
+++ b/providers/common/ai/docs/supported_services.rst
@@ -23,6 +23,9 @@ talk to, and the systems its toolsets talk to. For per-model 
detail beyond the v
 (exact model ids, pricing, capabilities), see `pydantic-ai's model list
 <https://ai.pydantic.dev/models/>`__.
 
+This page is the complete list of what this provider reaches; for a curated, 
install- and
+credential-oriented walkthrough of each model vendor, see 
:doc:`model_providers`.
+
 Connections
 -----------
 
@@ -31,7 +34,30 @@ Connections
 Toolsets
 --------
 
-.. provider-toolset-services:: apache-airflow-providers-common-ai
+.. list-table::
+   :header-rows: 1
+   :widths: 30 70
+
+   * - Toolset
+     - External services
+   * - :doc:`HookToolset <toolsets/hook>`
+     - Any Airflow connection, through its provider hook
+   * - :doc:`SQLToolset <toolsets/sql>`
+     - Any common.sql database connection (with table allowlists and AST 
validation)
+   * - :doc:`DataFusionToolset <toolsets/datafusion>`
+     - Object storage (S3, local filesystem, Apache Iceberg) via Apache 
DataFusion
+   * - :doc:`LoggingToolset <toolsets/logging>`
+     - Airflow task logs
+   * - :doc:`MCPToolset <toolsets/mcp>`
+     - Any MCP server (user-supplied endpoint)
+   * - :doc:`SandboxToolset <sandbox/index>`
+     - Docker Sandboxes (the shipped sbx backend; other backends can be added 
via SandboxBackend)
+   * - :doc:`AgentSkillsToolset <toolsets/skills>`
+     - Agent skills from local paths and remote Git repositories
+   * - :doc:`LangChain Bridge <toolsets/langchain>`
+     - LangChain tools
+   * - :doc:`Managed Agent Toolsets <toolsets/managed_agent>`
+     - Snowflake Cortex Agents; Amazon Bedrock AgentCore; Azure AI Foundry; 
Vertex AI Agent Engine
 
 Notes
 -----
@@ -45,7 +71,7 @@ Notes
   (:doc:`connections/pydantic_ai_azure`, :doc:`connections/pydantic_ai_vertex`,
   :doc:`connections/pydantic_ai_bedrock`) for their non-standard auth.
 * Most model providers need an extra installed alongside 
``apache-airflow-providers-common-ai``
-  — see the "Choosing extras" section of :doc:`index`.
+  — see the "Choosing extras" section of :doc:`installation`.
 * "Pydantic AI Gateway" in the ``pydanticai`` row is a routing layer, not an 
upstream vendor
   in its own right: set the connection's Model field to 
``gateway/<vendor>:<model>`` (for
   example ``gateway/anthropic:claude-sonnet-5``) to send the request through 
it instead of
diff --git a/providers/common/ai/provider.yaml 
b/providers/common/ai/provider.yaml
index e7490068335..3fb1b944939 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -576,37 +576,6 @@ toolsets:
       - airflow.providers.common.ai.toolsets.skills
       - airflow.providers.common.ai.toolsets.langchain_bridge
       - airflow.providers.common.ai.toolsets.managed_agent
-    external-services:
-      - module: airflow.providers.common.ai.toolsets.hook
-        services:
-          - Any Airflow connection, through its provider hook
-      - module: airflow.providers.common.ai.toolsets.sql
-        services:
-          - Any common.sql database connection (with table allowlists and AST 
validation)
-      - module: airflow.providers.common.ai.toolsets.datafusion
-        services:
-          - Object storage (S3, local filesystem, Apache Iceberg) via Apache 
DataFusion
-      - module: airflow.providers.common.ai.toolsets.logging
-        services:
-          - Airflow task logs
-      - module: airflow.providers.common.ai.toolsets.mcp
-        services:
-          - Any MCP server (user-supplied endpoint)
-      - module: airflow.providers.common.ai.toolsets.sandbox
-        services:
-          - Docker Sandboxes (the shipped sbx backend; other backends can be 
added via SandboxBackend)
-      - module: airflow.providers.common.ai.toolsets.skills
-        services:
-          - Agent skills from local paths and remote Git repositories
-      - module: airflow.providers.common.ai.toolsets.langchain_bridge
-        services:
-          - LangChain tools
-      - module: airflow.providers.common.ai.toolsets.managed_agent
-        services:
-          - Snowflake Cortex Agents
-          - Amazon Bedrock AgentCore
-          - Azure AI Foundry
-          - Vertex AI Agent Engine
 
 retry-policies:
   - integration-name: Common AI
diff --git a/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py 
b/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py
index d684257cc6b..ddecbc9919c 100644
--- a/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py
+++ b/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py
@@ -27,6 +27,7 @@ from __future__ import annotations
 
 import importlib
 import pkgutil
+import re
 from importlib.metadata import version
 from pathlib import Path
 
@@ -332,26 +333,66 @@ class TestPydanticAIExternalServicesDrift:
         )
 
 
+# Module basename -> (display name, `:doc:` target) expected in 
`supported_services.rst`'s
+# hand-written Toolsets table. Kept here (not in `src/`) because it is test 
data, not
+# runtime behaviour, the same way `LABELS` above is.
+TOOLSET_TABLE_ENTRIES: dict[str, tuple[str, str]] = {
+    "hook": ("HookToolset", "toolsets/hook"),
+    "sql": ("SQLToolset", "toolsets/sql"),
+    "datafusion": ("DataFusionToolset", "toolsets/datafusion"),
+    "logging": ("LoggingToolset", "toolsets/logging"),
+    "mcp": ("MCPToolset", "toolsets/mcp"),
+    "sandbox": ("SandboxToolset", "sandbox/index"),
+    "skills": ("AgentSkillsToolset", "toolsets/skills"),
+    "langchain_bridge": ("LangChain Bridge", "toolsets/langchain"),
+    "managed_agent": ("Managed Agent Toolsets", "toolsets/managed_agent"),
+}
+
+
 class TestToolsetExternalServices:
-    def test_every_toolset_module_has_external_services(self):
+    def test_every_toolset_module_has_a_table_entry(self):
+        """
+        `TOOLSET_TABLE_ENTRIES` is test data, not runtime behaviour (see 
`LABELS` above): every
+        module in `provider.yaml`'s `toolsets[].python-modules` must have an 
entry here, or the
+        hand-written table in `supported_services.rst` silently omits a 
toolset.
+        """
         toolset_block = _extract_toolset_block()
-        modules = set(toolset_block["python-modules"])
-        services_by_module = {
-            entry["module"]: entry["services"] for entry in 
toolset_block["external-services"]
-        }
+        for module in toolset_block["python-modules"]:
+            basename = module.rsplit(".", 1)[-1]
+            assert basename in TOOLSET_TABLE_ENTRIES, (
+                f"No table entry for toolset module {module!r}. Add an entry 
for {basename!r} "
+                f"to TOOLSET_TABLE_ENTRIES."
+            )
 
-        assert set(services_by_module) == modules, (
-            f"toolsets.external-services modules {sorted(services_by_module)} 
!= "
-            f"python-modules {sorted(modules)}"
+    def test_no_table_entry_is_stale(self):
+        """The opposite direction: catches a removed toolset module whose row 
nobody deleted."""
+        toolset_block = _extract_toolset_block()
+        basenames = {module.rsplit(".", 1)[-1] for module in 
toolset_block["python-modules"]}
+        stale = set(TOOLSET_TABLE_ENTRIES) - basenames
+        assert not stale, (
+            f"TOOLSET_TABLE_ENTRIES has entry(ies) for removed toolset 
module(s): {sorted(stale)}"
         )
-        for module, services in services_by_module.items():
-            assert services, f"toolset module {module!r} has an empty services 
list"
 
-    def test_every_toolset_module_has_a_docs_anchor(self):
-        toolsets_rst = (PROVIDER_YAML_PATH.parent / "docs" / 
"toolsets.rst").read_text()
-        toolset_block = _extract_toolset_block()
+    def test_toolset_table_entries_match_supported_services_rst(self):
+        """
+        Guard `supported_services.rst`'s hand-written Toolsets table against 
drifting from
+        `TOOLSET_TABLE_ENTRIES`, in both directions: every entry's `:doc:` 
target must appear in
+        the Toolsets section, and no *other* `:doc:` target may appear there 
either.
+        """
+        rst_text = (PROVIDER_YAML_PATH.parent / "docs" / 
"supported_services.rst").read_text()
+        start = rst_text.index("Toolsets\n--------")
+        end = rst_text.index("Notes\n-----", start)
+        toolsets_section = rst_text[start:end]
 
-        for module in toolset_block["python-modules"]:
-            basename = module.rsplit(".", 1)[-1]
-            anchor = f".. _howto/toolset:{basename}:"
-            assert anchor in toolsets_rst, f"missing docs anchor {anchor!r} 
for module {module!r}"
+        rendered_targets = set(re.findall(r":doc:`[^<]*<([^>]+)>`", 
toolsets_section))
+        expected_targets = {doc_target for _, doc_target in 
TOOLSET_TABLE_ENTRIES.values()}
+
+        assert rendered_targets == expected_targets, (
+            f"supported_services.rst's Toolsets section links to 
{sorted(rendered_targets)}, "
+            f"expected {sorted(expected_targets)}"
+        )
+
+    def test_toolset_table_entry_targets_exist(self):
+        for basename, (_, doc_target) in TOOLSET_TABLE_ENTRIES.items():
+            target_path = PROVIDER_YAML_PATH.parent / "docs" / 
f"{doc_target}.rst"
+            assert target_path.exists(), f"{doc_target}.rst (for module 
{basename!r}) does not exist"

Reply via email to