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 194e0ea4c98 Add a generated supported services page for the common.ai 
provider (#72939)
194e0ea4c98 is described below

commit 194e0ea4c9890a7eed1b6df19eb72159f3e44841
Author: Wei Lee <[email protected]>
AuthorDate: Mon Oct 12 22:17:10 2026 +0900

    Add a generated supported services page for the common.ai provider (#72939)
---
 .../src/sphinx_exts/providers_extensions.py        |  34 +-
 .../provider_connection_services.rst.jinja2        |  28 ++
 .../unit/sphinx_exts/test_providers_extensions.py  |  57 +++
 docs/spelling_wordlist.txt                         |   5 +
 providers/common/ai/docs/connections/langchain.rst |  10 +-
 providers/common/ai/docs/index.rst                 |   1 +
 providers/common/ai/docs/model_providers.rst       |   3 +
 providers/common/ai/docs/supported_services.rst    |  82 ++++
 providers/common/ai/provider.yaml                  |  32 +-
 .../providers/common/ai/get_provider_info.py       |  31 +-
 .../providers/common/ai/hooks/pydantic_ai.py       |   2 +-
 .../tests/unit/common/ai/test_provider_metadata.py | 478 +++++++++++++++++++++
 12 files changed, 749 insertions(+), 14 deletions(-)

diff --git a/devel-common/src/sphinx_exts/providers_extensions.py 
b/devel-common/src/sphinx_exts/providers_extensions.py
index 5aa40ea9b59..70be9ebbb59 100644
--- a/devel-common/src/sphinx_exts/providers_extensions.py
+++ b/devel-common/src/sphinx_exts/providers_extensions.py
@@ -25,13 +25,12 @@ from pathlib import Path
 from typing import Any
 
 # No stub exists for docutils.parsers.rst.directives. See 
https://github.com/python/typeshed/issues/5755.
-from provider_yaml_utils import load_package_data
-
 from sphinx_exts.operators_and_hooks_ref import (
     DEFAULT_HEADER_SEPARATOR,
     BaseJinjaReferenceDirective,
     _render_template,
 )
+from sphinx_exts.provider_yaml_utils import load_package_data
 
 
 def find_class_methods_with_specific_calls(
@@ -515,8 +514,39 @@ class 
OpenLineageSupportedClassesDirective(BaseJinjaReferenceDirective):
         return _render_openlineage_supported_classes_content()
 
 
+def _find_provider_package_data(package_name: str) -> dict[str, Any]:
+    for provider in load_package_data():
+        if provider["package-name"] == package_name:
+            return provider
+    raise ValueError(f"No provider.yaml found for package '{package_name}'")
+
+
+def _render_connection_services_content(package_name: str) -> str:
+    provider = _find_provider_package_data(package_name)
+    rows = [
+        {
+            "hook_name": conn["hook-name"],
+            "services": conn.get("external-services") or [],
+            "ref": f"howto/connection:{conn['connection-type']}",
+        }
+        for conn in provider.get("connection-types", [])
+    ]
+    return _render_template("provider_connection_services.rst.jinja2", 
rows=rows)
+
+
+class ProviderConnectionServicesDirective(BaseJinjaReferenceDirective):
+    """Render a table of a provider's connection types 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_connection_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)
 
     return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git 
a/devel-common/src/sphinx_exts/templates/provider_connection_services.rst.jinja2
 
b/devel-common/src/sphinx_exts/templates/provider_connection_services.rst.jinja2
new file mode 100644
index 00000000000..5d7bb00b66d
--- /dev/null
+++ 
b/devel-common/src/sphinx_exts/templates/provider_connection_services.rst.jinja2
@@ -0,0 +1,28 @@
+{#
+ 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
+
+   * - Connection type
+     - External services
+   {% for row in rows -%}
+   * - :ref:`{{ row.hook_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
new file mode 100644
index 00000000000..ac0e130a31d
--- /dev/null
+++ b/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py
@@ -0,0 +1,57 @@
+# 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 unittest import mock
+
+import pytest
+
+from sphinx_exts.providers_extensions import (
+    _render_connection_services_content,
+)
+
+FAKE_PACKAGE_DATA = [
+    {
+        "package-name": "apache-airflow-providers-example",
+        "connection-types": [
+            {
+                "hook-class-name": 
"airflow.providers.example.hooks.example.ExampleHook",
+                "hook-name": "Example",
+                "connection-type": "example",
+                "external-services": ["OpenAI", "Anthropic"],
+            }
+        ],
+    }
+]
+
+
[email protected]("sphinx_exts.providers_extensions.load_package_data", 
autospec=True)
+def 
test_render_connection_services_content_renders_one_row_per_connection_type(mock_load_package_data):
+    mock_load_package_data.return_value = FAKE_PACKAGE_DATA
+
+    rendered = 
_render_connection_services_content("apache-airflow-providers-example")
+
+    assert ":ref:`Example <howto/connection:example>`" in rendered
+    assert "OpenAI, Anthropic" in rendered
+
+
[email protected]("sphinx_exts.providers_extensions.load_package_data", 
autospec=True)
+def 
test_render_connection_services_content_unknown_package_raises(mock_load_package_data):
+    mock_load_package_data.return_value = FAKE_PACKAGE_DATA
+
+    with pytest.raises(ValueError, match="No provider.yaml found"):
+        
_render_connection_services_content("apache-airflow-providers-does-not-exist")
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index d7e3d0cdfe2..38b1d770bbf 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -233,6 +233,7 @@ ccache
 cdc
 celltags
 centric
+Cerebras
 cfg
 cgi
 cgitb
@@ -327,6 +328,7 @@ contextmgr
 contrib
 convertToIri
 cooldown
+Copilot
 copyable
 coreutils
 CoreV
@@ -1157,6 +1159,7 @@ NCUs
 nd
 ndjson
 nearText
+Nebius
 neighbours
 neo
 Neo4j
@@ -1929,6 +1932,7 @@ Vectorizers
 vendored
 venv
 venvs
+Vercel
 Vertica
 vertica
 Vespa
@@ -1981,6 +1985,7 @@ WTF
 wtf
 wtforms
 www
+xAI
 XCom
 Xcom
 xcom
diff --git a/providers/common/ai/docs/connections/langchain.rst 
b/providers/common/ai/docs/connections/langchain.rst
index f5b4c54bcc7..4abd2de4293 100644
--- a/providers/common/ai/docs/connections/langchain.rst
+++ b/providers/common/ai/docs/connections/langchain.rst
@@ -71,11 +71,11 @@ Supported providers
 
 The hook forwards two values to LangChain: the connection's password as
 ``api_key`` and its host as ``base_url``. Any provider whose LangChain model
-class accepts those two keyword arguments works, which includes OpenAI,
-Anthropic, Groq, Mistral AI, DeepSeek, Ollama and vLLM. Providers whose
-classes expect their own credential shape (AWS Bedrock, Google Vertex AI and
-GenAI, Azure OpenAI, Cohere, HuggingFace) reject these kwargs and are not
-usable through this connection type.
+class accepts those two keyword arguments works. Providers whose classes
+expect their own credential shape (AWS Bedrock, Google Vertex AI and GenAI,
+Azure OpenAI, Cohere, HuggingFace) reject these kwargs and are not usable
+through this connection type. See :doc:`../supported_services` for the
+current list of providers this connection type reaches.
 
 Model resolution order
 -----------------------
diff --git a/providers/common/ai/docs/index.rst 
b/providers/common/ai/docs/index.rst
index 3fa3dc509ca..51da806596f 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -105,6 +105,7 @@ waits for the result, use that vendor's provider.
     What you can build <use_cases/index>
     Connections <connections/index>
     Models and providers <model_providers>
+    Supported services <supported_services>
     Operators and decorators <operators/index>
     Toolsets <toolsets/index>
     Agent frameworks <frameworks/index>
diff --git a/providers/common/ai/docs/model_providers.rst 
b/providers/common/ai/docs/model_providers.rst
index 1dab82f35c4..3bff8e2360e 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
new file mode 100644
index 00000000000..047e2a5a525
--- /dev/null
+++ b/providers/common/ai/docs/supported_services.rst
@@ -0,0 +1,82 @@
+ .. 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.
+
+Supported services
+===================
+
+``common.ai`` reaches external services on two axes: the model providers its 
connections
+talk to, and the systems its toolsets talk to. For per-model detail beyond the 
vendor level
+(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
+-----------
+
+.. provider-connection-services:: apache-airflow-providers-common-ai
+
+Toolsets
+--------
+
+.. 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:`ObjectStorageToolset <toolsets/object_storage>`
+     - Object storage (S3, GCS, Azure Blob Storage, or any store
+       :class:`~airflow.sdk.ObjectStoragePath` can open), read-only
+   * - :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
+-----
+
+* The ``langchain`` connection type's row above is limited to 
OpenAI-compatible credential
+  surfaces (``api_key`` + optional ``base_url``); see :ref:`Supported providers
+  <langchain-supported-providers>` for the providers that reject those kwargs 
and are not
+  usable through it.
+* Azure OpenAI, Google Vertex AI, and AWS Bedrock are also reachable through 
the generic
+  ``pydanticai`` connection type, but each has its own dedicated connection 
type
+  (: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:`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
+  directly to the vendor. It currently routes to Anthropic, AWS Bedrock, 
Google Gemini,
+  Google Vertex AI, Groq, and OpenAI — all already listed above in their own 
right.
diff --git a/providers/common/ai/provider.yaml 
b/providers/common/ai/provider.yaml
index 5bfea3d5276..fb3ccdb768e 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -162,15 +162,39 @@ connection-types:
     hook-name: "Pydantic AI"
     connection-type: pydanticai
     external-services:
-      - OpenAI
+      - Alibaba Cloud Model Studio
       - Anthropic
-      - Google
       - AWS Bedrock
+      - AWS Bedrock Mantle
+      - Azure OpenAI
+      - Cerebras
+      - Cohere
+      - Crusoe
+      - DeepSeek
+      - Fireworks AI
+      - GitHub Copilot
+      - Google Gemini
+      - Google Vertex AI
       - Groq
+      - Heroku
+      - Hugging Face
+      - LiteLLM
       - Mistral AI
-      - DeepSeek
+      - Moonshot AI
+      - Nebius
       - Ollama
+      - OpenAI
+      - OpenRouter
+      - OVHcloud
+      - Pydantic AI Gateway
+      - SambaNova
+      - Snowflake Cortex
+      - Together AI
+      - TypeSafe
+      - Vercel AI Gateway
       - vLLM
+      - xAI
+      - Z.AI
     ui-field-behaviour:
       hidden-fields:
         - schema
@@ -415,6 +439,8 @@ connection-types:
   - hook-class-name: airflow.providers.common.ai.hooks.mcp.MCPHook
     hook-name: "MCP Server"
     connection-type: mcp
+    external-services:
+      - Any MCP server (user-supplied endpoint)
     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 d8e6b25054c..f50b3b5256a 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
@@ -137,15 +137,39 @@ def get_provider_info():
                 "hook-name": "Pydantic AI",
                 "connection-type": "pydanticai",
                 "external-services": [
-                    "OpenAI",
+                    "Alibaba Cloud Model Studio",
                     "Anthropic",
-                    "Google",
                     "AWS Bedrock",
+                    "AWS Bedrock Mantle",
+                    "Azure OpenAI",
+                    "Cerebras",
+                    "Cohere",
+                    "Crusoe",
+                    "DeepSeek",
+                    "Fireworks AI",
+                    "GitHub Copilot",
+                    "Google Gemini",
+                    "Google Vertex AI",
                     "Groq",
+                    "Heroku",
+                    "Hugging Face",
+                    "LiteLLM",
                     "Mistral AI",
-                    "DeepSeek",
+                    "Moonshot AI",
+                    "Nebius",
                     "Ollama",
+                    "OpenAI",
+                    "OpenRouter",
+                    "OVHcloud",
+                    "Pydantic AI Gateway",
+                    "SambaNova",
+                    "Snowflake Cortex",
+                    "Together AI",
+                    "TypeSafe",
+                    "Vercel AI Gateway",
                     "vLLM",
+                    "xAI",
+                    "Z.AI",
                 ],
                 "ui-field-behaviour": {
                     "hidden-fields": ["schema", "port", "login"],
@@ -328,6 +352,7 @@ def get_provider_info():
                 "hook-class-name": 
"airflow.providers.common.ai.hooks.mcp.MCPHook",
                 "hook-name": "MCP Server",
                 "connection-type": "mcp",
+                "external-services": ["Any MCP server (user-supplied 
endpoint)"],
                 "ui-field-behaviour": {
                     "hidden-fields": ["schema", "port", "login"],
                     "relabeling": {"password": "Auth Token"},
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py 
b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
index efb34edaac0..6c3d4429db7 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
@@ -91,7 +91,7 @@ class PydanticAIHook(BaseHook):
     Hook for LLM access via pydantic-ai.
 
     Covers providers that use a standard ``api_key`` + optional ``base_url``
-    (OpenAI, Anthropic, Groq, Mistral, DeepSeek, Ollama, vLLM, …).
+    (OpenAI, Anthropic, Groq, Mistral, DeepSeek, Ollama, vLLM, GitHub Copilot, 
…).
 
     For cloud providers with non-standard auth use the dedicated subclasses:
     :class:`PydanticAIAzureHook`, :class:`PydanticAIBedrockHook`,
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
new file mode 100644
index 00000000000..c628c3a24e6
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py
@@ -0,0 +1,478 @@
+# 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.
+"""
+Drift tripwires between ``provider.yaml``'s declared ``external-services`` and 
what
+pydantic-ai actually installs.
+
+How ``LABELS``, ``NON_MODULE_SERVICES``, ``UNREACHABLE_MODULES``,
+``DEPRECATED_UPSTREAM_MODULES`` and ``HOOK_INCOMPATIBLE_AUTH_MODULES`` below 
were derived,
+including the empirical checks that back each exclusion, is documented in the 
comment directly
+above each constant.
+"""
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import pkgutil
+import re
+from importlib.metadata import version
+from pathlib import Path
+
+import pydantic_ai.providers as pydantic_ai_providers
+import pytest
+import yaml
+from packaging.version import Version
+from pydantic_ai.exceptions import UserError
+from pydantic_ai.models import infer_model, known_model_names
+
+from airflow.providers.common.ai.get_provider_info import get_provider_info
+
+# `toolsets` never flowed through the runtime `provider_info.schema.json`
+# (memory `reference_two_provider_schemas_only_authoring_may_tighten`), so
+# `get_provider_info()` doesn't carry it -- read `provider.yaml` directly 
instead.
+PROVIDER_YAML_PATH = Path(__file__).resolve().parents[4] / "provider.yaml"
+
+
+def _extract_toolset_block() -> dict:
+    provider_yaml = yaml.safe_load(PROVIDER_YAML_PATH.read_text())
+    (toolset_block,) = provider_yaml["toolsets"]
+    return toolset_block
+
+
+# Module basename -> display label used in `provider.yaml`'s 
`pydanticai.external-services`.
+# Sourced from each module's own `name` property / class docstring; kept here 
(not in
+# `src/`) because it is test data, not runtime behaviour.
+LABELS = {
+    "alibaba": "Alibaba Cloud Model Studio",
+    "anthropic": "Anthropic",
+    "azure": "Azure OpenAI",
+    "bedrock": "AWS Bedrock",
+    "bedrock_mantle": "AWS Bedrock Mantle",
+    "cerebras": "Cerebras",
+    "cohere": "Cohere",
+    "crusoe": "Crusoe",
+    "deepseek": "DeepSeek",
+    "fireworks": "Fireworks AI",
+    "gateway": "Pydantic AI Gateway",
+    "github_copilot": "GitHub Copilot",
+    "google": "Google Gemini",
+    "google_cloud": "Google Vertex AI",
+    "groq": "Groq",
+    "heroku": "Heroku",
+    "huggingface": "Hugging Face",
+    "litellm": "LiteLLM",
+    "mistral": "Mistral AI",
+    "moonshotai": "Moonshot AI",
+    "nebius": "Nebius",
+    "ollama": "Ollama",
+    "openai": "OpenAI",
+    "openrouter": "OpenRouter",
+    "ovhcloud": "OVHcloud",
+    "sambanova": "SambaNova",
+    "snowflake": "Snowflake Cortex",
+    "together": "Together AI",
+    "typesafe": "TypeSafe",
+    "vercel": "Vercel AI Gateway",
+    "vllm": "vLLM",
+    "xai": "xAI",
+    "zai": "Z.AI",
+}
+
+# Declared in `pydanticai.external-services` but not backed by a 
`pydantic_ai.providers` module of
+# its own -- reached instead through some other provider's constructor kwargs. 
Currently empty:
+# `vLLM` was the sole member here (reached by pointing the `openai` provider's 
`base_url` at a
+# vLLM endpoint, `hooks/pydantic_ai.py`) until pydantic-ai 2.44.0 added a 
dedicated
+# `pydantic_ai.providers.vllm` module with its own `name` property (`'vllm'`) 
-- see
+# `LABELS["vllm"]` above. Kept, rather than deleted, as where a future vendor 
without its own
+# module would go.
+NON_MODULE_SERVICES: set[str] = set()
+
+# Modules `pkgutil` discovers under `pydantic_ai.providers` that `infer_model` 
never
+# routes to -- `infer_model("voyageai:...")` / 
`infer_model("sentence-transformers:...")`
+# raise `UserError: Unknown model`, confirmed by 
`test_unreachable_modules_stay_unreachable`
+# below. Not listed in `LABELS`/`provider.yaml` because there is no 
`common.ai` code path
+# (chat or embedding) that reaches them.
+UNREACHABLE_MODULES = {"voyageai", "sentence_transformers"}
+
+# Modules that ARE reachable through `infer_model` (unlike 
UNREACHABLE_MODULES) but are
+# excluded by decision because pydantic-ai itself marks the upstream backend 
dead:
+# `pydantic_ai.providers.github.GitHubProvider` is decorated `@deprecated` 
with the message
+# "GitHub Models was retired on 2026-07-30 ... this provider is deprecated and 
will be
+# removed in v3." There is no GitHub Copilot provider anywhere in pydantic-ai; 
the only
+# "github" support that ever existed is this now-retired integration. 
Advertising a vendor
+# whose backend the vendor itself shut down is worse than not listing it 
(Wei's call,
+# 2026-09-11 review round). `test_deprecated_upstream_modules_stay_deprecated` 
below
+# guards the reversal: if pydantic-ai un-deprecates this module, or a real 
GitHub Copilot
+# provider appears under a different module name, the corresponding test goes 
red instead
+# of silently staying excluded.
+DEPRECATED_UPSTREAM_MODULES = {"github"}
+
+# Modules that ARE reachable through `infer_model` (unlike 
UNREACHABLE_MODULES) but are excluded
+# by decision because `PydanticAIHook` cannot supply the credentials they 
require from a connection.
+# `PydanticAIHook._get_provider_kwargs()` -- the generic connection type's 
only credential path
+# (`hooks/pydantic_ai.py`) -- always sends `api_key` (from `conn.password`) 
and/or `base_url`
+# (from `conn.host`). 
`pydantic_ai.providers.openai_codex.OpenAICodexProvider.__init__` accepts
+# only `credentials`, `credential_source`, `openai_client`, and `http_client` 
-- no parameter
+# exists to receive what the hook sends; confirmed empirically (as of 
pydantic-ai-slim 2.44.0):
+# `OpenAICodexProvider(api_key=..., base_url=...)` raises
+# ``TypeError: unexpected keyword argument 'api_key'``. 
`PydanticAIHook.get_conn()`'s
+# `_provider_factory` catches that `TypeError` and retries via 
`infer_provider(pname)`, i.e.
+# `OpenAICodexProvider()` with no arguments, which falls through to its
+# `_read_codex_cli_credentials()` helper: a read-only load of the Codex CLI's 
on-disk
+# `~/.codex/auth.json`, populated by the OAuth authorization-code + PKCE login 
flow the Codex CLI
+# itself performs (see `pydantic_ai.providers.openai_codex`'s module docstring 
and
+# `OpenAICodexOAuthFlow`) -- not by anything `hooks/pydantic_ai.py` can 
express. So credentials
+# configured on a `pydanticai` connection (password/host) can never drive this 
provider; only
+# the ambient Codex CLI login can, via that fallback. The services page lists 
only vendors a
+# connection's credentials can drive, so these modules stay off it.
+# `test_hook_incompatible_auth_modules_stay_incompatible` below guards the 
reversal: if
+# pydantic-ai gives one of these providers an `api_key`/`base_url` constructor 
parameter, this
+# must fail so a human re-evaluates whether the module belongs back in 
`LABELS` and
+# `provider.yaml`, instead of it staying excluded forever on a stale rationale.
+HOOK_INCOMPATIBLE_AUTH_MODULES = {"openai_codex"}
+
+# `pydantic_ai.providers.openai_codex` (the sole 
`HOOK_INCOMPATIBLE_AUTH_MODULES` member) was
+# added in pydantic-ai-slim 2.41.0 -- absent on 2.40.0 and below (bisected 
empirically; the
+# provider's floor is 2.33.0, `pyproject.toml`). The guard test below imports 
it unconditionally,
+# so on the floor it would raise `ModuleNotFoundError` instead of testing 
anything.
+OPENAI_CODEX_AVAILABLE_FROM = Version("2.41.0")
+
+requires_openai_codex_module = pytest.mark.skipif(
+    Version(Version(version("pydantic-ai-slim")).base_version) < 
OPENAI_CODEX_AVAILABLE_FROM,
+    reason=(
+        f"pydantic-ai-slim is older than {OPENAI_CODEX_AVAILABLE_FROM}, the 
first release "
+        f"carrying pydantic_ai.providers.openai_codex"
+    ),
+)
+
+# `provider.yaml`'s vendor list is derived from a current pydantic-ai, but the 
provider
+# supports `pydantic-ai-slim>=2.33.0` (`pyproject.toml`) and CI's "Low dep 
tests" job installs
+# exactly that floor. Vendors upstream added after the floor are legitimately 
absent there --
+# bisected empirically: `vllm` landed in 2.38.0, `github_copilot` in 2.42.0, 
and `typesafe` in
+# 2.45.0 (`openai_codex` also landed after the floor, in 2.41.0, but is 
excluded from this
+# reasoning -- see `OPENAI_CODEX_AVAILABLE_FROM` above) -- so only the 
"installed but not
+# declared" direction holds on every supported version. The opposite direction 
("declared but
+# not installed") is a real drift signal solely from the release that carries 
every vendor
+# currently declared, i.e. the latest of the three, `typesafe`'s 2.45.0: raise 
this when a
+# newly declared vendor lands in a later release.
+ALL_DECLARED_VENDORS_AVAILABLE_FROM = Version("2.45.0")
+
+requires_all_declared_vendors = pytest.mark.skipif(
+    Version(Version(version("pydantic-ai-slim")).base_version) < 
ALL_DECLARED_VENDORS_AVAILABLE_FROM,
+    reason=(
+        f"pydantic-ai-slim is older than 
{ALL_DECLARED_VENDORS_AVAILABLE_FROM}, the first release "
+        f"carrying every vendor provider.yaml declares"
+    ),
+)
+
+# `known_model_names()` prefixes that don't match a `pydantic_ai.providers` 
module basename
+# directly (hyphen normalized to underscore) but still resolve through 
`infer_model`.
+PREFIX_ALIASES = {
+    "openai-chat": "openai",
+    "openai-responses": "openai",
+    "azure-responses": "azure",
+}
+
+# The vendors `supported_services.rst`'s Notes section names, in hand-written 
prose, as the
+# ones `gateway/<vendor>:<model>` currently routes to. Deliberately kept as a 
human-readable
+# sentence there rather than a generated list (2026-09-11 review round: this 
is a customer-
+# facing page, not a place to trade readability for testability) -- this 
constant is the
+# reference value the two tests below check it against, in both directions:
+# `test_gateway_note_vendors_match_known_model_names` guards against 
pydantic-ai adding/dropping
+# a gateway vendor, and 
`test_gateway_note_sentence_names_the_expected_vendors` guards against
+# the prose and this constant drifting apart from each other.
+GATEWAY_NOTE_VENDOR_LABELS = {
+    "Anthropic",
+    "AWS Bedrock",
+    "Google Gemini",
+    "Google Vertex AI",
+    "Groq",
+    "OpenAI",
+}
+
+
+def _find_discovered_provider_modules() -> set[str]:
+    """All non-private modules under `pydantic_ai.providers`, including 
`gateway`.
+
+    `gateway` is a real, reachable upstream service 
(`PydanticAIHook.get_conn()` forwards
+    a `gateway/<vendor>:<model>` model string straight to `infer_model`, which 
resolves it
+    to a working `Model` instance), so it is not excluded here.
+    """
+    return {
+        m.name for m in pkgutil.iter_modules(pydantic_ai_providers.__path__) 
if not m.name.startswith("_")
+    }
+
+
+def _find_reachable_provider_modules() -> set[str]:
+    """The discovered modules `common.ai` actually reaches, i.e. minus the 
documented exclusions."""
+    return (
+        _find_discovered_provider_modules()
+        - UNREACHABLE_MODULES
+        - DEPRECATED_UPSTREAM_MODULES
+        - HOOK_INCOMPATIBLE_AUTH_MODULES
+    )
+
+
+def _find_derived_vendor_labels() -> set[str]:
+    """The vendor labels the *installed* pydantic-ai implies `provider.yaml` 
should declare."""
+    return {
+        LABELS.get(m, f"<unlabelled:{m}>") for m in 
_find_reachable_provider_modules()
+    } | NON_MODULE_SERVICES
+
+
+def _provider_classes_in_module(mod) -> list[type]:
+    """The ``*Provider`` classes defined in ``mod`` itself; fails if there are 
none."""
+    provider_classes = [
+        obj
+        for obj in vars(mod).values()
+        if isinstance(obj, type) and obj.__module__ == mod.__name__ and 
obj.__name__.endswith("Provider")
+    ]
+    assert provider_classes, f"no Provider class found in {mod.__name__}"
+    return provider_classes
+
+
+def _find_pydanticai_external_services() -> list[str]:
+    connection_types = get_provider_info()["connection-types"]
+    (pydanticai,) = (c for c in connection_types if c["connection-type"] == 
"pydanticai")
+    return pydanticai["external-services"]
+
+
+class TestConnectionTypeExternalServices:
+    def test_every_connection_type_declares_external_services(self):
+        for conn in get_provider_info()["connection-types"]:
+            assert conn.get("external-services"), (
+                f"connection-type {conn['connection-type']!r} has no 
external-services"
+            )
+
+
+class TestPydanticAIExternalServicesDrift:
+    def test_every_installed_vendor_is_declared(self):
+        """
+        The direction that holds on every supported pydantic-ai: a vendor the 
installed release
+        can reach must be declared, or the page silently under-advertises what 
the provider does.
+        """
+        missing = _find_derived_vendor_labels() - 
set(_find_pydanticai_external_services())
+        assert not missing, f"pydantic-ai vendors not declared in 
provider.yaml: {sorted(missing)}"
+
+    @requires_all_declared_vendors
+    def test_no_declared_vendor_is_absent_upstream(self):
+        """
+        The opposite direction, gated on 
``ALL_DECLARED_VENDORS_AVAILABLE_FROM``: once every
+        declared vendor exists upstream, one that pydantic-ai does not have is 
either a typo or a
+        vendor whose module was dropped, and the page would overclaim what the 
provider reaches.
+        """
+        extra = set(_find_pydanticai_external_services()) - 
_find_derived_vendor_labels()
+        assert not extra, f"provider.yaml declares vendors pydantic-ai doesn't 
have: {sorted(extra)}"
+
+    def test_every_installed_module_has_a_label(self):
+        """As `test_every_installed_vendor_is_declared`, for `LABELS` rather 
than `provider.yaml`."""
+        unlabelled = _find_reachable_provider_modules() - set(LABELS)
+        assert not unlabelled, f"no label for pydantic-ai module(s): 
{sorted(unlabelled)}"
+
+    @requires_all_declared_vendors
+    def test_no_label_is_stale(self):
+        """As `test_no_declared_vendor_is_absent_upstream`, for `LABELS`."""
+        stale = set(LABELS) - _find_reachable_provider_modules()
+        assert not stale, f"label(s) for module(s) pydantic-ai no longer has: 
{sorted(stale)}"
+
+    def test_unreachable_modules_stay_unreachable(self):
+        """
+        Guard the other direction: if a future pydantic-ai release makes one of
+        ``UNREACHABLE_MODULES`` reachable through ``infer_model``, this must 
fail so a human
+        adds it to ``LABELS`` and ``provider.yaml`` instead of it silently 
staying missing.
+        """
+        for module in UNREACHABLE_MODULES:
+            prefix = module.replace("_", "-")
+            with pytest.raises(UserError, match="Unknown model"):
+                infer_model(f"{prefix}:placeholder", provider_factory=lambda 
_p: object())
+
+    def test_deprecated_upstream_modules_stay_deprecated(self):
+        """
+        Guard the other direction, for every member of 
``DEPRECATED_UPSTREAM_MODULES`` (not just
+        ``github`` -- this set is the one escape hatch in this file that isn't 
validated against
+        pydantic-ai by name elsewhere, so adding a module here that isn't 
genuinely
+        upstream-deprecated must not pass silently). If pydantic-ai 
un-deprecates a member
+        (reactivating its backend, or repurposing the module), this must fail 
so a human
+        re-evaluates whether it belongs back in ``LABELS`` and 
``provider.yaml``, instead of it
+        staying excluded forever on a stale rationale.
+        """
+        for module in DEPRECATED_UPSTREAM_MODULES:
+            try:
+                mod = 
importlib.import_module(f"pydantic_ai.providers.{module}")
+            except ImportError as exc:
+                # Deliberately not `pytest.importorskip`: an unmet optional 
dependency here
+                # would silently turn off this guard, not just this one test 
run.
+                pytest.fail(
+                    f"pydantic_ai.providers.{module} (in 
DEPRECATED_UPSTREAM_MODULES) failed to "
+                    f"import: {exc}. Install the missing dependency rather 
than skipping this "
+                    f"test."
+                )
+            provider_classes = _provider_classes_in_module(mod)
+            assert all(hasattr(cls, "__deprecated__") for cls in 
provider_classes), (
+                f"pydantic_ai.providers.{module} is no longer marked 
@deprecated -- re-evaluate "
+                f"whether '{module}' should be re-added to LABELS and 
provider.yaml"
+            )
+
+    @requires_openai_codex_module
+    def test_hook_incompatible_auth_modules_stay_incompatible(self):
+        """
+        Guard the other direction, for every member of 
``HOOK_INCOMPATIBLE_AUTH_MODULES``: if
+        pydantic-ai gives one of these providers an ``api_key``/``base_url`` 
constructor path (the
+        shape ``PydanticAIHook._get_provider_kwargs`` sends), this must fail 
so a human
+        re-evaluates whether the module belongs back in ``LABELS`` and 
``provider.yaml``, instead
+        of it staying excluded forever on a stale rationale.
+        """
+        for module in HOOK_INCOMPATIBLE_AUTH_MODULES:
+            mod = importlib.import_module(f"pydantic_ai.providers.{module}")
+            provider_classes = _provider_classes_in_module(mod)
+            for cls in provider_classes:
+                params = inspect.signature(cls.__init__).parameters
+                assert "api_key" not in params, (
+                    f"{cls.__name__} now accepts api_key -- re-evaluate 
whether '{module}' "
+                    f"should be re-added to LABELS and provider.yaml"
+                )
+                assert "base_url" not in params, (
+                    f"{cls.__name__} now accepts base_url -- re-evaluate 
whether '{module}' "
+                    f"should be re-added to LABELS and provider.yaml"
+                )
+
+    def test_known_model_name_prefixes_are_all_labelled(self):
+        labelled = set(LABELS)
+        for name in known_model_names():
+            if ":" not in name:
+                continue  # the bare "test" entry, pydantic-ai's built-in 
keyless TestModel
+            prefix = name.removeprefix("gateway/").split(":", 1)[0]
+            module = PREFIX_ALIASES.get(prefix, prefix.replace("-", "_"))
+            assert module in labelled, f"model prefix {prefix!r} (from 
{name!r}) has no label"
+
+    def test_gateway_note_vendors_match_known_model_names(self):
+        """
+        Guard `supported_services.rst`'s Notes sentence naming which vendors
+        ``gateway/<vendor>:<model>`` currently routes to. If pydantic-ai's 
gateway adds or drops
+        a vendor, this must fail so a human updates that sentence (and
+        ``GATEWAY_NOTE_VENDOR_LABELS``) instead of it silently going stale.
+        """
+        gateway_prefixes = {
+            name.split("/", 1)[1].split(":", 1)[0]
+            for name in known_model_names()
+            if name.startswith("gateway/")
+        }
+        modules = {PREFIX_ALIASES.get(p, p.replace("-", "_")) for p in 
gateway_prefixes}
+        derived = {LABELS.get(m, f"<unlabelled:{m}>") for m in modules}
+
+        assert derived == GATEWAY_NOTE_VENDOR_LABELS, (
+            f"gateway now routes to {sorted(derived)} -- update the Notes 
sentence in "
+            f"supported_services.rst and GATEWAY_NOTE_VENDOR_LABELS to match"
+        )
+
+    def test_gateway_note_sentence_names_the_expected_vendors(self):
+        """
+        Guard the prose itself, in both directions: every vendor in
+        ``GATEWAY_NOTE_VENDOR_LABELS`` must be named in 
`supported_services.rst`'s gateway Notes
+        bullet specifically -- not just somewhere in the file, where several 
of these vendor
+        names already appear in other sentences -- and no *other* labelled 
vendor may be named
+        in that bullet either. Editing the bullet without updating the 
constant above (or vice
+        versa) must be caught either way: underclaiming a vendor is a stale 
doc, overclaiming one
+        is a false statement about what this service reaches.
+        """
+        rst_text = (PROVIDER_YAML_PATH.parent / "docs" / 
"supported_services.rst").read_text()
+        marker = '* "Pydantic AI Gateway" in the'
+        start = rst_text.index(marker)
+        end = rst_text.find("\n* ", start)
+        gateway_bullet = rst_text[start:] if end == -1 else rst_text[start:end]
+        gateway_bullet = " ".join(gateway_bullet.split())
+
+        for vendor in GATEWAY_NOTE_VENDOR_LABELS:
+            assert vendor in gateway_bullet, (
+                f"{vendor!r} missing from supported_services.rst's gateway 
Notes bullet"
+            )
+
+        # `LABELS["gateway"]` ("Pydantic AI Gateway") is excluded: it is 
itself a value of
+        # `LABELS` and rightfully appears in this bullet (it's the row's own 
name), so it must
+        # not be flagged as an "other" vendor the bullet shouldn't mention.
+        other_labels = set(LABELS.values()) - GATEWAY_NOTE_VENDOR_LABELS - 
{LABELS["gateway"]}
+        unexpected = sorted(vendor for vendor in other_labels if vendor in 
gateway_bullet)
+        assert not unexpected, (
+            f"gateway Notes bullet names {unexpected}, which are not in 
GATEWAY_NOTE_VENDOR_LABELS"
+        )
+
+
+# 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"),
+    "object_storage": ("ObjectStorageToolset", "toolsets/object_storage"),
+    "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_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()
+        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."
+            )
+
+    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)}"
+        )
+
+    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]
+
+        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