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

potiuk 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 caf26246c1c Stop pydantic-ai providers that models cannot use from 
failing the common.ai vendor check (#76312)
caf26246c1c is described below

commit caf26246c1c695e9dfecc71ae2c99c7801a3106f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Nov 4 15:26:03 2026 +0100

    Stop pydantic-ai providers that models cannot use from failing the 
common.ai vendor check (#76312)
    
    Each pydantic-ai release that adds a provider module infer_model cannot 
route to, such as the generic OpenAI-compatible provider in 2.63.0, failed the 
provider.yaml drift tests until someone listed it by hand, which blocked the 
automated CI environment upgrade. Whether infer_model routes to a module can be 
asked of the installed pydantic-ai itself, so only a new vendor a connection 
can actually reach still needs a human.
    
    Generated-by: Claude Opus 5
---
 .../tests/unit/common/ai/test_provider_metadata.py | 101 ++++++++++++++-------
 1 file changed, 68 insertions(+), 33 deletions(-)

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 16ae1309350..f630485f8c4 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
@@ -18,7 +18,7 @@
 Drift tripwires between ``provider.yaml``'s declared ``external-services`` and 
what
 pydantic-ai actually installs.
 
-How ``LABELS``, ``NON_MODULE_SERVICES``, ``UNREACHABLE_MODULES``,
+How ``LABELS``, ``NON_MODULE_SERVICES``,
 ``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.
@@ -32,6 +32,7 @@ import pkgutil
 import re
 from importlib.metadata import version
 from pathlib import Path
+from typing import Any, cast
 
 import pydantic_ai.providers as pydantic_ai_providers
 import pytest
@@ -105,19 +106,10 @@ LABELS = {
 # 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. `elevenlabs` (added in 
pydantic-ai-slim 2.59.0) is
-# the same: its provider only backs ElevenLabs speech/realtime models, which 
`infer_model`
-# does not route to (`UserError: Unknown model: elevenlabs:...`, checked on 
2.59.0).
-# `openai_compatible` (added in pydantic-ai-slim 2.63.0) is a generic provider 
for any
-# OpenAI-compatible endpoint that must be constructed with a service `name`; 
`infer_model` has no
-# prefix for it (`UserError: Unknown model: openai-compatible:...`, checked on 
2.63.0).
-UNREACHABLE_MODULES = {"voyageai", "sentence_transformers", "elevenlabs", 
"openai_compatible"}
-
-# Modules that ARE reachable through `infer_model` (unlike 
UNREACHABLE_MODULES) but are
+# `gateway` is reached as `gateway/<vendor>:<model>`; `gateway:<model>` is an 
unknown model.
+INFER_MODEL_PROBES = {"gateway": "gateway/openai:placeholder"}
+
+# Modules that ARE reachable through `infer_model` 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
@@ -130,7 +122,7 @@ UNREACHABLE_MODULES = {"voyageai", "sentence_transformers", 
"elevenlabs", "opena
 # of silently staying excluded.
 DEPRECATED_UPSTREAM_MODULES = {"github"}
 
-# Modules that ARE reachable through `infer_model` (unlike 
UNREACHABLE_MODULES) but are excluded
+# Modules that ARE reachable through `infer_model` 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`
@@ -239,14 +231,63 @@ def _find_discovered_provider_modules() -> set[str]:
     }
 
 
+def _find_provider_names(module: str) -> set[str]:
+    """
+    The ``name`` of each ``*Provider`` class in ``module``, which 
``infer_model`` matches as the
+    model prefix, read without building a provider; empty when the module 
needs an optional SDK.
+    """
+    try:
+        mod = importlib.import_module(f"pydantic_ai.providers.{module}")
+    except ImportError:
+        return set()
+    names = set()
+    for cls in _find_provider_classes(mod):
+        name = inspect.getattr_static(cls, "name", None)
+        try:
+            value = name.fget(None) if isinstance(name, property) and 
name.fget else name
+        except Exception:
+            continue
+        if isinstance(value, str):
+            names.add(value)
+    return names
+
+
+def _routes_to_a_model(model: str) -> bool:
+    try:
+        infer_model(model, provider_factory=lambda _p: 
cast("pydantic_ai_providers.Provider[Any]", object()))
+    except UserError as exc:
+        return not str(exc).startswith("Unknown model")
+    except Exception:
+        # A missing optional SDK or the placeholder provider fails only after 
routing.
+        return True
+    return True
+
+
+def _is_routed_by_infer_model(module: str) -> bool:
+    """
+    Whether ``infer_model`` routes a model string to ``module``.
+
+    Modules it never routes to (embedding-only ones such as ``voyageai``, 
speech-only ones such
+    as ``elevenlabs``, or ``openai_compatible`` from pydantic-ai-slim 2.63.0, 
which needs a
+    service name to be built) have no ``common.ai`` code path, so they are 
neither labelled nor
+    declared. The prefix is tried both as the module name and as each 
provider's own ``name``, so
+    a vendor whose prefix differs from its module name is not mistaken for an 
unreachable one.
+    """
+    if module in INFER_MODEL_PROBES:
+        return _routes_to_a_model(INFER_MODEL_PROBES[module])
+    prefixes = {module.replace("_", "-")} | _find_provider_names(module)
+    return any(_routes_to_a_model(f"{prefix}:placeholder") for prefix in 
prefixes)
+
+
 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
+    return {
+        module
+        for module in _find_discovered_provider_modules()
         - DEPRECATED_UPSTREAM_MODULES
         - HOOK_INCOMPATIBLE_AUTH_MODULES
-    )
+        if _is_routed_by_infer_model(module)
+    }
 
 
 def _find_derived_vendor_labels() -> set[str]:
@@ -256,13 +297,18 @@ def _find_derived_vendor_labels() -> set[str]:
     } | 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 = [
+def _find_provider_classes(mod) -> list[type]:
+    """The ``*Provider`` classes defined in ``mod`` itself."""
+    return [
         obj
         for obj in vars(mod).values()
         if isinstance(obj, type) and obj.__module__ == mod.__name__ and 
obj.__name__.endswith("Provider")
     ]
+
+
+def _provider_classes_in_module(mod) -> list[type]:
+    """The ``*Provider`` classes defined in ``mod`` itself; fails if there are 
none."""
+    provider_classes = _find_provider_classes(mod)
     assert provider_classes, f"no Provider class found in {mod.__name__}"
     return provider_classes
 
@@ -332,17 +378,6 @@ class TestPydanticAIExternalServicesDrift:
         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

Reply via email to