This is an automated email from the ASF dual-hosted git repository.
Lee-W 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 43384c7d8bc Show which LLM providers each Common AI connection type
reaches (#70497)
43384c7d8bc is described below
commit 43384c7d8bcf1f96b65a88067e002ffb70e6461e
Author: Wei Lee <[email protected]>
AuthorDate: Thu Aug 6 18:01:53 2026 +0800
Show which LLM providers each Common AI connection type reaches (#70497)
---
airflow-core/src/airflow/provider.yaml.schema.json | 8 ++++
.../23_provider_hook_migration_to_yaml.rst | 26 ++++++++++
dev/registry/extract_metadata.py | 6 ++-
dev/registry/extract_versions.py | 1 +
dev/registry/registry_contract_models.py | 1 +
dev/registry/tests/test_extract_metadata.py | 56 ++++++++++++++++++++++
dev/registry/tests/test_extract_versions.py | 40 ++++++++++++++++
.../tests/test_registry_contract_models.py | 19 ++++++++
providers/common/ai/provider.yaml | 28 +++++++++++
.../providers/common/ai/get_provider_info.py | 24 ++++++++++
registry/src/css/main.css | 34 +++++++++++++
registry/src/provider-version.njk | 24 ++++++++++
12 files changed, 266 insertions(+), 1 deletion(-)
diff --git a/airflow-core/src/airflow/provider.yaml.schema.json
b/airflow-core/src/airflow/provider.yaml.schema.json
index 0a700aa88d5..376eacdf93f 100644
--- a/airflow-core/src/airflow/provider.yaml.schema.json
+++ b/airflow-core/src/airflow/provider.yaml.schema.json
@@ -471,6 +471,14 @@
},
"additionalProperties": false
},
+ "external-services": {
+ "description": "Representative, non-exhaustive list of
upstream provider/service names this connection type is known to reach over the
network (e.g. 'Anthropic', 'AWS Bedrock', 'Ollama') -- examples, not an
exhaustive compatibility matrix. Hooks that resolve the destination from a
caller-supplied model identifier (as with pydantic-ai) can reach any service
the model id names, so the list can never be complete. Not to be confused with
the top-level 'integrations' k [...]
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
"conn-fields": {
"description": "Custom connection fields stored in
Connection.extra JSON",
"type": "object",
diff --git a/contributing-docs/23_provider_hook_migration_to_yaml.rst
b/contributing-docs/23_provider_hook_migration_to_yaml.rst
index f751f9675a9..54706f6c4dc 100644
--- a/contributing-docs/23_provider_hook_migration_to_yaml.rst
+++ b/contributing-docs/23_provider_hook_migration_to_yaml.rst
@@ -60,6 +60,32 @@ Customizations for standard connection fields:
placeholders:
port: '5432'
+external-services
+~~~~~~~~~~~~~~~~~
+
+List of upstream provider or model-serving services that this connection type
+reaches over the network (e.g. ``OpenAI``, ``AWS Bedrock``, ``Ollama``). The
registry
+renders this list as a table on the provider's version page, so someone
browsing the
+registry can see at a glance which external services a given connection type
talks
+to. Because some hooks resolve the destination from a caller-supplied model
+identifier (for example ``PydanticAIHook``), this list is representative, not
+exhaustive -- treat it as example services, not a compatibility matrix.
+
+This is unrelated to the top-level ``integrations`` key in ``provider.yaml``,
which
+describes *framework* integrations (e.g. Kubernetes, Docker) and drives the
+provider's docs pages, logo, and tags. ``external-services`` only applies
inside a
+``connection-types`` entry and only lists services reached over the network —
it has
+no effect on docs generation, logos, or tags.
+
+.. code-block:: yaml
+
+ connection-types:
+ - hook-class-name:
airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIAzureHook
+ hook-name: "Pydantic AI (Azure OpenAI)"
+ connection-type: pydanticai-azure
+ external-services:
+ - Azure OpenAI
+
conn-fields
~~~~~~~~~~~
diff --git a/dev/registry/extract_metadata.py b/dev/registry/extract_metadata.py
index 1c557a3a8fa..77d34d8397d 100644
--- a/dev/registry/extract_metadata.py
+++ b/dev/registry/extract_metadata.py
@@ -373,7 +373,9 @@ class Provider:
}
)
categories: list[dict] = field(default_factory=list)
- connection_types: list[dict] = field(default_factory=list) # {conn_type,
hook_class, docs_url}
+ connection_types: list[dict] = field(
+ default_factory=list
+ ) # {conn_type, hook_class, docs_url, external_services}
requires_python: str = "" # e.g., ">=3.10"
dependencies: list[str] = field(default_factory=list) # from
pyproject.toml
optional_extras: dict[str, list[str]] = field(default_factory=dict) #
{extra_name: [deps]}
@@ -782,12 +784,14 @@ def main():
for conn in provider_yaml.get("connection-types", []):
conn_type = conn.get("connection-type", "")
hook_class = conn.get("hook-class-name", "")
+ external_services = conn.get("external-services", [])
if conn_type:
connection_types.append(
{
"conn_type": conn_type,
"hook_class": hook_class,
"docs_url": resolve_connection_docs_url(conn_type,
conn_url_map, base_docs_url),
+ "external_services": external_services,
}
)
diff --git a/dev/registry/extract_versions.py b/dev/registry/extract_versions.py
index c47d91a7043..064fd4a8f86 100644
--- a/dev/registry/extract_versions.py
+++ b/dev/registry/extract_versions.py
@@ -449,6 +449,7 @@ def extract_version_data(
"conn_type": conn_type,
"hook_class": ct.get("hook-class-name", ""),
"docs_url": resolve_connection_docs_url(conn_type,
conn_url_map, base_docs_url),
+ "external_services": ct.get("external-services", []),
}
)
diff --git a/dev/registry/registry_contract_models.py
b/dev/registry/registry_contract_models.py
index 7295cfb4036..475b14d5d0f 100644
--- a/dev/registry/registry_contract_models.py
+++ b/dev/registry/registry_contract_models.py
@@ -52,6 +52,7 @@ class ConnectionTypeContract(BaseModel):
conn_type: str
hook_class: str = ""
docs_url: str | None = None
+ external_services: list[str] = Field(default_factory=list)
class ProviderContract(BaseModel):
diff --git a/dev/registry/tests/test_extract_metadata.py
b/dev/registry/tests/test_extract_metadata.py
index e4e98fcdc69..faf17f6f9c8 100644
--- a/dev/registry/tests/test_extract_metadata.py
+++ b/dev/registry/tests/test_extract_metadata.py
@@ -20,6 +20,7 @@ from __future__ import annotations
import http.client
import json
+import sys
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -36,6 +37,7 @@ from extract_metadata import (
find_latest_released_version,
find_related_providers,
load_release_tags,
+ main,
module_path_to_file_path,
parse_pyproject_toml,
preserve_nonzero_downloads,
@@ -828,3 +830,57 @@ class TestVersionsListFiltering:
filtered = [v for v in raw_versions if f"providers-{provider_id}/{v}"
in release_tags]
# Order from raw_versions is preserved; only the phantom is dropped
assert filtered == ["9.26.0", "9.25.0", "9.24.0"]
+
+
+# ---------------------------------------------------------------------------
+# main() -- connection-types `external-services` propagation
+# ---------------------------------------------------------------------------
+class TestMainConnectionTypesExternalServices:
+ """The connection-types extraction loop lives inline in main() rather than
+ a standalone function, so this drives main() end-to-end (with network and
+ filesystem dependencies mocked/redirected) to prove `external-services`
+ from provider.yaml reaches the written providers.json, and round-trips
+ through the `ConnectionTypeContract` (extra="forbid") validation main()
+ already runs -- catching a key-name drift between provider.yaml, this
+ script, and registry_contract_models.py.
+ """
+
+ @patch("extract_metadata.fetch_provider_inventory", autospec=True,
return_value=None)
+ @patch("extract_metadata.fetch_pypi_data_parallel", autospec=True,
return_value={})
+ @patch("extract_metadata.load_release_tags", autospec=True,
return_value=set())
+ def test_external_services_propagates_to_providers_json(
+ self, _load_release_tags, _fetch_pypi_data_parallel,
_fetch_provider_inventory, tmp_path
+ ):
+ providers_dir = tmp_path / "providers"
+ provider_dir = providers_dir / "testprov"
+ provider_dir.mkdir(parents=True)
+ (provider_dir / "provider.yaml").write_text(
+ textwrap.dedent("""\
+ name: Test Provider
+ description: A test provider.
+ versions:
+ - 1.0.0
+ connection-types:
+ - connection-type: testconn
+ hook-class-name: airflow.providers.test.hooks.TestHook
+ external-services:
+ - openai
+ - anthropic
+ """)
+ )
+ output_dir = tmp_path / "output"
+ script_dir = tmp_path / "script"
+ output_dir.mkdir()
+ script_dir.mkdir()
+
+ with (
+ patch("extract_metadata.PROVIDERS_DIR", providers_dir),
+ patch("extract_metadata.OUTPUT_DIR", output_dir),
+ patch("extract_metadata.SCRIPT_DIR", script_dir),
+ patch.object(sys, "argv", ["extract_metadata.py"]),
+ ):
+ main()
+
+ written = json.loads((output_dir / "providers.json").read_text())
+ provider = next(p for p in written["providers"] if p["id"] ==
"testprov")
+ assert provider["connection_types"][0]["external_services"] ==
["openai", "anthropic"]
diff --git a/dev/registry/tests/test_extract_versions.py
b/dev/registry/tests/test_extract_versions.py
index a2c8ce33575..061424eb918 100644
--- a/dev/registry/tests/test_extract_versions.py
+++ b/dev/registry/tests/test_extract_versions.py
@@ -18,12 +18,16 @@
from __future__ import annotations
+import textwrap
+from unittest.mock import patch
+
import pytest
from extract_versions import (
AIRFLOW_ROOT,
PROVIDERS_JSON_CANDIDATES,
SCRIPT_DIR,
extract_modules_from_yaml,
+ extract_version_data,
)
from registry_tools.types import CLASS_LEVEL_SECTIONS,
DICT_SHAPED_CLASS_LEVEL_SECTIONS
@@ -170,3 +174,39 @@ class TestExtractModulesFromYamlDictShapedSections:
modules = _extract_class_level_modules(provider_yaml)
assert {m["type"] for m in modules} == {t for t, _, _ in
DICT_SHAPED_CLASS_LEVEL_SECTIONS.values()}
+
+
+class TestExtractVersionDataConnectionTypes:
+ """`external-services` on a connection-types entry must survive into the
+ per-version metadata.json (extract_versions.py:399) the same way it does
+ for the latest release in providers.json (extract_metadata.py). A
+ superseded release only has this file as its data source, so a dropped
+ or mis-keyed field here silently vanishes from just that version's page.
+ """
+
+ PROVIDER_YAML = textwrap.dedent("""\
+ name: Test Provider
+ connection-types:
+ - connection-type: testconn
+ hook-class-name: airflow.providers.test.hooks.TestHook
+ external-services:
+ - openai
+ - anthropic
+ """)
+
+ @patch("extract_versions.extract_modules_from_yaml", autospec=True,
return_value=[])
+ @patch("extract_versions.fetch_provider_inventory", autospec=True,
return_value=None)
+ @patch("extract_versions.git_show", autospec=True)
+ @patch("extract_versions.detect_layout", autospec=True, return_value="new")
+ @patch("extract_versions.git_tag_exists", autospec=True, return_value=True)
+ def test_external_services_propagates_to_version_metadata(
+ self, _tag_exists, _layout, mock_git_show, _inventory, _modules
+ ):
+ mock_git_show.side_effect = lambda tag, path: (
+ self.PROVIDER_YAML if path.endswith("provider.yaml") else None
+ )
+
+ result = extract_version_data("test", "1.0.0", "test")
+
+ assert result is not None
+ assert result["connection_types"][0]["external_services"] ==
["openai", "anthropic"]
diff --git a/dev/registry/tests/test_registry_contract_models.py
b/dev/registry/tests/test_registry_contract_models.py
index 120c682da78..2a98d848261 100644
--- a/dev/registry/tests/test_registry_contract_models.py
+++ b/dev/registry/tests/test_registry_contract_models.py
@@ -21,6 +21,7 @@ from __future__ import annotations
import pytest
from pydantic import ValidationError
from registry_contract_models import (
+ ConnectionTypeContract,
build_openapi_document,
validate_modules_catalog,
validate_provider_parameters,
@@ -100,6 +101,24 @@ def
test_module_contract_preserves_supports_durable_execution_true():
assert validated["modules"][0]["supports_durable_execution"] is True
+def test_connection_type_contract_defaults_external_services_to_empty_list():
+ """Legacy connection-types entries (provider.yaml without
`external-services`)
+ must still validate, with the field defaulting to an empty list."""
+ validated = ConnectionTypeContract.model_validate({"conn_type": "test",
"hook_class": "x.y.Hook"})
+ assert validated.external_services == []
+
+
+def test_connection_type_contract_round_trips_external_services():
+ validated = ConnectionTypeContract.model_validate(
+ {
+ "conn_type": "test",
+ "hook_class": "x.y.Hook",
+ "external_services": ["openai", "anthropic"],
+ }
+ )
+ assert validated.external_services == ["openai", "anthropic"]
+
+
def
test_validate_version_metadata_accepts_legacy_version_modules_without_ids():
payload = {
"provider_id": "test",
diff --git a/providers/common/ai/provider.yaml
b/providers/common/ai/provider.yaml
index 1feaa6ed5c6..b6133e2e2e4 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -147,6 +147,16 @@ connection-types:
- hook-class-name:
airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook
hook-name: "Pydantic AI"
connection-type: pydanticai
+ external-services:
+ - OpenAI
+ - Anthropic
+ - Google
+ - AWS Bedrock
+ - Groq
+ - Mistral AI
+ - DeepSeek
+ - Ollama
+ - vLLM
ui-field-behaviour:
hidden-fields:
- schema
@@ -167,6 +177,8 @@ connection-types:
- hook-class-name:
airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIAzureHook
hook-name: "Pydantic AI (Azure OpenAI)"
connection-type: pydanticai-azure
+ external-services:
+ - Azure OpenAI
ui-field-behaviour:
hidden-fields:
- schema
@@ -195,6 +207,8 @@ connection-types:
- hook-class-name:
airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIBedrockHook
hook-name: "Pydantic AI (AWS Bedrock)"
connection-type: pydanticai-bedrock
+ external-services:
+ - AWS Bedrock
ui-field-behaviour:
hidden-fields:
- schema
@@ -278,6 +292,8 @@ connection-types:
- hook-class-name:
airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIVertexHook
hook-name: "Pydantic AI (Google Vertex AI)"
connection-type: pydanticai-vertex
+ external-services:
+ - Google Vertex AI
ui-field-behaviour:
hidden-fields:
- schema
@@ -374,6 +390,14 @@ connection-types:
- hook-class-name: airflow.providers.common.ai.hooks.langchain.LangChainHook
hook-name: "LangChain"
connection-type: langchain
+ external-services:
+ - OpenAI
+ - Anthropic
+ - Groq
+ - Mistral AI
+ - DeepSeek
+ - Ollama
+ - vLLM
ui-field-behaviour:
hidden-fields:
- schema
@@ -407,6 +431,10 @@ connection-types:
- hook-class-name:
airflow.providers.common.ai.hooks.llamaindex.LlamaIndexHook
hook-name: "LlamaIndex"
connection-type: llamaindex
+ external-services:
+ - OpenAI
+ - Ollama
+ - vLLM
ui-field-behaviour:
hidden-fields:
- schema
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
index 89d96737d94..96e87e5945d 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
@@ -120,6 +120,17 @@ def get_provider_info():
"hook-class-name":
"airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook",
"hook-name": "Pydantic AI",
"connection-type": "pydanticai",
+ "external-services": [
+ "OpenAI",
+ "Anthropic",
+ "Google",
+ "AWS Bedrock",
+ "Groq",
+ "Mistral AI",
+ "DeepSeek",
+ "Ollama",
+ "vLLM",
+ ],
"ui-field-behaviour": {
"hidden-fields": ["schema", "port", "login"],
"relabeling": {"password": "API Key"},
@@ -137,6 +148,7 @@ def get_provider_info():
"hook-class-name":
"airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIAzureHook",
"hook-name": "Pydantic AI (Azure OpenAI)",
"connection-type": "pydanticai-azure",
+ "external-services": ["Azure OpenAI"],
"ui-field-behaviour": {
"hidden-fields": ["schema", "port", "login"],
"relabeling": {"password": "API Key", "host": "Azure
Endpoint"},
@@ -159,6 +171,7 @@ def get_provider_info():
"hook-class-name":
"airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIBedrockHook",
"hook-name": "Pydantic AI (AWS Bedrock)",
"connection-type": "pydanticai-bedrock",
+ "external-services": ["AWS Bedrock"],
"ui-field-behaviour": {
"hidden-fields": ["schema", "port", "login", "host",
"password"],
"relabeling": {},
@@ -221,6 +234,7 @@ def get_provider_info():
"hook-class-name":
"airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIVertexHook",
"hook-name": "Pydantic AI (Google Vertex AI)",
"connection-type": "pydanticai-vertex",
+ "external-services": ["Google Vertex AI"],
"ui-field-behaviour": {
"hidden-fields": ["schema", "port", "login", "host",
"password"],
"relabeling": {},
@@ -295,6 +309,15 @@ def get_provider_info():
"hook-class-name":
"airflow.providers.common.ai.hooks.langchain.LangChainHook",
"hook-name": "LangChain",
"connection-type": "langchain",
+ "external-services": [
+ "OpenAI",
+ "Anthropic",
+ "Groq",
+ "Mistral AI",
+ "DeepSeek",
+ "Ollama",
+ "vLLM",
+ ],
"ui-field-behaviour": {
"hidden-fields": ["schema", "port", "login"],
"relabeling": {"password": "API Key"},
@@ -319,6 +342,7 @@ def get_provider_info():
"hook-class-name":
"airflow.providers.common.ai.hooks.llamaindex.LlamaIndexHook",
"hook-name": "LlamaIndex",
"connection-type": "llamaindex",
+ "external-services": ["OpenAI", "Ollama", "vLLM"],
"ui-field-behaviour": {
"hidden-fields": ["schema", "port", "login"],
"relabeling": {"password": "API Key"},
diff --git a/registry/src/css/main.css b/registry/src/css/main.css
index 3e0b077f429..ef006000248 100644
--- a/registry/src/css/main.css
+++ b/registry/src/css/main.css
@@ -3761,6 +3761,40 @@ main {
border-color: var(--color-green-400);
}
+/* Per-connection external services matrix */
+.provider-detail-page .connections-card .conn-external-services-table {
+ width: 100%;
+ margin-top: var(--space-4);
+ border-collapse: collapse;
+}
+
+.provider-detail-page .connections-card .conn-external-services-header th {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--text-sm);
+ font-weight: var(--font-semibold);
+ color: var(--text-secondary);
+ text-align: left;
+ background: var(--bg-tertiary);
+}
+
+.provider-detail-page .connections-card .conn-external-services-row {
+ border-top: 1px solid var(--border-primary);
+}
+
+.provider-detail-page .connections-card .conn-external-services-row td {
+ padding: var(--space-2) var(--space-3);
+ vertical-align: top;
+}
+
+.provider-detail-page .connections-card .conn-external-services-row
td:first-child {
+ white-space: nowrap;
+}
+
+.provider-detail-page .connections-card .conn-external-services-list {
+ color: var(--text-secondary);
+ font-size: var(--text-sm);
+}
+
/* Shared builder panel */
.provider-detail-page .connections-card .conn-builder-panel {
margin-top: var(--space-3);
diff --git a/registry/src/provider-version.njk
b/registry/src/provider-version.njk
index c76be316bec..ec56d1167f8 100644
--- a/registry/src/provider-version.njk
+++ b/registry/src/provider-version.njk
@@ -248,6 +248,30 @@ eleventyComputed:
</button>
{% endfor %}
</div>
+ {% set hasExternalServices = false %}
+ {% for conn in conns %}
+ {% if conn.external_services and conn.external_services.length > 0
%}{% set hasExternalServices = true %}{% endif %}
+ {% endfor %}
+ {% if hasExternalServices %}
+ <table class="conn-external-services-table">
+ <thead>
+ <tr class="conn-external-services-header">
+ <th>Connection type</th>
+ <th>External services (examples)</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for conn in conns %}
+ {% if conn.external_services and conn.external_services.length > 0 %}
+ <tr class="conn-external-services-row">
+ <td><code>{{ conn.conn_type or conn.connection_type }}</code></td>
+ <td class="conn-external-services-list">{{ conn.external_services
| join(", ") }}</td>
+ </tr>
+ {% endif %}
+ {% endfor %}
+ </tbody>
+ </table>
+ {% endif %}
<div class="conn-builder-panel" id="conn-builder-panel" hidden>
<div class="conn-builder-header">
<span class="conn-builder-title" id="conn-builder-title"></span>