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 6e990ea1927 Make common.ai hooks report misconfiguration instead of
failing silently (#74451)
6e990ea1927 is described below
commit 6e990ea1927c71aedb34e842d27a4780049d297f
Author: Wei Lee <[email protected]>
AuthorDate: Mon Oct 26 14:17:57 2026 +0800
Make common.ai hooks report misconfiguration instead of failing silently
(#74451)
---
providers/common/ai/docs/changelog.rst | 32 ++++++
providers/common/ai/docs/connections/mcp.rst | 3 +-
.../ai/docs/connections/pydantic_ai_azure.rst | 13 ++-
.../ai/docs/connections/pydantic_ai_vertex.rst | 10 +-
providers/common/ai/docs/provider_fallback.rst | 16 +--
providers/common/ai/docs/self_hosted_models.rst | 10 +-
providers/common/ai/docs/toolsets/index.rst | 9 +-
providers/common/ai/provider.yaml | 2 +-
.../ai/example_dags/example_llm_retry_policy.py | 7 +-
.../providers/common/ai/get_provider_info.py | 2 +-
.../airflow/providers/common/ai/hooks/langchain.py | 29 ++++-
.../providers/common/ai/hooks/llamaindex.py | 46 ++++++--
.../src/airflow/providers/common/ai/hooks/mcp.py | 55 +++++++--
.../providers/common/ai/hooks/pydantic_ai.py | 46 ++++----
.../airflow/providers/common/ai/policies/retry.py | 5 +-
.../tests/unit/common/ai/hooks/test_langchain.py | 74 ++++++++++++
.../tests/unit/common/ai/hooks/test_llamaindex.py | 84 ++++++++++++++
.../ai/tests/unit/common/ai/hooks/test_mcp.py | 128 +++++++++++++++++++++
.../tests/unit/common/ai/hooks/test_pydantic_ai.py | 71 ++++++++++--
.../ai/tests/unit/common/ai/policies/test_retry.py | 65 ++++++++---
.../tests/unit/common/ai/test_provider_metadata.py | 7 +-
.../unit/snowflake/hooks/test_cortex_model.py | 18 ++-
22 files changed, 618 insertions(+), 114 deletions(-)
diff --git a/providers/common/ai/docs/changelog.rst
b/providers/common/ai/docs/changelog.rst
index 4093a86db8f..ee9c5ac9552 100644
--- a/providers/common/ai/docs/changelog.rst
+++ b/providers/common/ai/docs/changelog.rst
@@ -48,6 +48,38 @@ Changelog
``"PT1M30S"`` for a ``timedelta``. Tuples and sets in that model now arrive
as lists. See
:doc:`structured_output`.
+.. warning::
+ The following changes are breaking for code written against 1.0.0:
+
+ * ``PydanticAIHook``: when a connection supplies credentials (password or
host) and the provider class
+ rejects the resulting keyword arguments (``TypeError``), the hook now
raises ``ValueError``. In 1.0.0
+ it logged a warning and fell back to environment-variable authentication,
which could authenticate
+ as a different identity than the connection. Fix the connection fields for
that provider, or remove
+ the credentials from the connection if environment-variable authentication
is what you want.
+ * ``airflow.providers.common.ai.policies.retry``: importing it without the
Airflow ``RetryPolicy``
+ support (Airflow older than 3.3) now raises
``AirflowOptionalProviderFeatureException`` instead of
+ ``ImportError``. It is not an ``ImportError`` subclass (it derives from
``AirflowException``), so a
+ Dag that guards the import with ``except ImportError:`` as in the 1.0.0
example Dag now fails to
+ parse on those versions. Catch both, importing the exception from
+ ``airflow.providers.common.compat.sdk`` (works on Airflow 2.11 and 3.x)::
+
+ from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+ try:
+ from airflow.providers.common.ai.policies.retry import LLMRetryPolicy
+ except (ImportError, AirflowOptionalProviderFeatureException):
+ LLMRetryPolicy = None
+
+.. note::
+ ``LlamaIndexHook`` and ``LangChainHook`` (both experimental, see
:doc:`stability`):
+ passing ``embed_model`` and ``llm_model`` positionally is deprecated. In
1.0.0 they were the third and
+ fourth positional parameters (after ``llm_conn_id`` and ``embed_conn_id``),
and the two hooks listed
+ them in opposite order (``embed_model`` then ``llm_model`` for
``LlamaIndexHook``, ``llm_model`` then
+ ``embed_model`` for ``LangChainHook``). Positional calls still work and emit
an
+ ``AirflowProviderDeprecationWarning``, but support will be removed in a
future release. Pass them by
+ keyword, e.g. ``LlamaIndexHook(llm_model="gpt-5")``, which also avoids the
meanings swapping between
+ the two hooks.
+
1.1.0
.....
diff --git a/providers/common/ai/docs/connections/mcp.rst
b/providers/common/ai/docs/connections/mcp.rst
index dc51df1cada..04cb73dcd34 100644
--- a/providers/common/ai/docs/connections/mcp.rst
+++ b/providers/common/ai/docs/connections/mcp.rst
@@ -70,7 +70,8 @@ Environment (Extra field)
Timeout (Extra field)
Connection init timeout in seconds for the ``stdio`` transport. Ignored
- for ``http``/``sse``. Default: ``10``.
+ for ``http``/``sse``. Must be a non-negative finite number; ``0`` or
``null``
+ disables the timeout. Default: ``10``.
Examples: ``30``
diff --git a/providers/common/ai/docs/connections/pydantic_ai_azure.rst
b/providers/common/ai/docs/connections/pydantic_ai_azure.rst
index 0332412d3b4..d26c416bca8 100644
--- a/providers/common/ai/docs/connections/pydantic_ai_azure.rst
+++ b/providers/common/ai/docs/connections/pydantic_ai_azure.rst
@@ -52,10 +52,15 @@ Model
this connection type's own platform, so nothing else needs naming it
explicitly. Writing the ``azure:`` prefix yourself has the same effect and
is
still accepted. A name prefixed with a *different*, recognized platform
(e.g.
- ``openai:gpt-5``) is used verbatim instead, pinning that platform and
- bypassing Azure OpenAI entirely -- a name is only treated as already
prefixed
- when the segment before its first ``:`` is itself a real pydantic-ai
- provider, not merely present.
+ ``openai:gpt-5``) is used verbatim instead -- a name is only treated as
+ already prefixed when the segment before its first ``:`` is itself a real
+ pydantic-ai provider, not merely present. That other platform's provider
+ class is built from the fields this connection forwards (``API Key``,
+ ``Azure Endpoint`` as ``azure_endpoint``, ``API Version``), so it works
only
+ if it accepts all of them. ``OpenAIProvider`` does not accept
+ ``azure_endpoint`` or ``api_version``, so ``openai:`` with an endpoint or
API
+ version set fails with a ``ValueError`` naming the fields passed; it never
+ silently falls back to environment-variable credentials.
API Key (Password field)
The Azure OpenAI API key.
diff --git a/providers/common/ai/docs/connections/pydantic_ai_vertex.rst
b/providers/common/ai/docs/connections/pydantic_ai_vertex.rst
index 03eb1290da4..3cd9e20852a 100644
--- a/providers/common/ai/docs/connections/pydantic_ai_vertex.rst
+++ b/providers/common/ai/docs/connections/pydantic_ai_vertex.rst
@@ -57,8 +57,12 @@ Model
mean Vertex AI Express Mode credentials (see "Credentials" below), so its
presence alone cannot tell the two platforms apart. To reach the
Generative Language API instead, prefix the model explicitly with
- ``google:`` -- that spelling routes to a different provider regardless of
- which fields this connection sets.
+ ``google:``. The ``google:`` provider accepts only the ``API Key`` and
+ ``Custom Endpoint URL`` fields (without an API key it reads
``GOOGLE_API_KEY``), so on this
+ connection type that spelling must not be combined with Vertex-only fields
+ (``GCP Project``, ``Location / Region``, ``Service Account Info``): the
+ connection fails with a ``ValueError`` instead of silently falling back to
+ environment-variable credentials.
GCP Project
Google Cloud project ID. Falls back to the ``GOOGLE_CLOUD_PROJECT``
@@ -87,7 +91,7 @@ API Key
``project`` / ``location`` / ``service_account_info`` (those select the
credentials/ADC path instead, which takes precedence and nulls the API
key). For the Generative Language API
- (non-Vertex, API-key-only), use the ``google:`` prefix on the generic
+ (non-Vertex; API key or ``GOOGLE_API_KEY``), use the ``google:`` prefix on
the generic
:doc:`pydantic_ai` connection instead.
Service Account Info
diff --git a/providers/common/ai/docs/provider_fallback.rst
b/providers/common/ai/docs/provider_fallback.rst
index a8a74574b5c..7b2821c1054 100644
--- a/providers/common/ai/docs/provider_fallback.rst
+++ b/providers/common/ai/docs/provider_fallback.rst
@@ -198,16 +198,12 @@ Two checks, neither of which requires waiting for a real
outage:
*Test the connection.* ``test_connection`` on the primary resolves every
connection in the
chain, so a fallback with a missing ``model`` or an unknown connection ID is
reported by name
-there rather than discovered mid-incident. Credential fields a provider class
rejects with a
-``TypeError`` are caught by the hook, which retries with the env-var-based
provider
-constructor and logs a warning either way; if the required env var is also
missing, that
-retry raises ``pydantic_ai.exceptions.UserError``, which ``test_connection``
does surface
-since it wraps the whole resolution in a broad exception handler. What it
cannot show is the
-opposite case: the env var *is* set on the worker, the retry quietly succeeds,
and
-``test_connection`` reports success even though the credentials you configured
on the
-connection were silently ignored -- check the logs for that warning rather
than relying on
-``test_connection`` alone. It also does not call the provider, so a
well-formed but revoked
-key still passes -- that is what the drill below is for.
+there rather than discovered mid-incident. Fields a provider class cannot be
built from (a
+``TypeError``) fail the resolution with a ``ValueError`` naming the
connection, the provider and
+the fields passed, with the provider's ``TypeError`` text -- the hook never
falls back to
+environment-variable auth, which would authenticate as a different identity --
and
+``test_connection`` reports it as a failure. It does not call the provider, so
a well-formed
+but revoked key still passes -- that is what the drill below is for.
*Drill it.* Point the primary at an endpoint nothing listens on and run the
Dag. The task
should still succeed, and the run summary in its log names the model that
answered:
diff --git a/providers/common/ai/docs/self_hosted_models.rst
b/providers/common/ai/docs/self_hosted_models.rst
index f454d0a2a2b..ce449cd65fa 100644
--- a/providers/common/ai/docs/self_hosted_models.rst
+++ b/providers/common/ai/docs/self_hosted_models.rst
@@ -317,11 +317,11 @@ its OpenAI provider, and there's no separate ``ollama``
extra.
``LiteLLMProvider`` is an exception: its constructor takes ``api_base``
instead of ``base_url``, so passing ``host`` raises a ``TypeError`` that
- the hook catches, logs a warning, and falls back to environment-variable
- auth, ignoring ``host`` entirely. Point a LiteLLM proxy's OpenAI-compatible
- endpoint via ``openai:<model>`` instead -- this is the same proxy role
- used in the gateway section above, just addressed through the working
- prefix rather than the rejected ``litellm:`` one.
+ the hook re-raises as a ``ValueError``: the connection is rejected rather
+ than falling back to environment-variable auth. Point a LiteLLM proxy's
+ OpenAI-compatible endpoint via ``openai:<model>`` instead -- this is the
+ same proxy role used in the gateway section above, just addressed through
+ the working prefix rather than the rejected ``litellm:`` one.
.. _self_hosted_models_other_servers:
diff --git a/providers/common/ai/docs/toolsets/index.rst
b/providers/common/ai/docs/toolsets/index.rst
index 8be3079b1d6..1e9351cbb4b 100644
--- a/providers/common/ai/docs/toolsets/index.rst
+++ b/providers/common/ai/docs/toolsets/index.rst
@@ -113,10 +113,11 @@ Every toolset imports from the
``airflow.providers.common.ai.toolsets`` package
SQLToolset,
)
-``SQLToolset`` and ``DataFusionToolset`` depend on optional extras. If the
extra is not
-installed, the first access to the name raises
``AirflowOptionalProviderFeatureException``.
-``MCPToolset`` imports without its extra; the missing dependency raises
``ImportError``
-when the toolset first connects to the MCP server.
+``SQLToolset``, ``DataFusionToolset`` and ``MCPToolset`` depend on optional
extras. If the
+extra is not installed, they raise
``AirflowOptionalProviderFeatureException``. For
+``SQLToolset`` and ``DataFusionToolset`` this happens on the first access to
the name.
+``MCPToolset`` imports without its extra; it raises only when the toolset
first connects
+to the MCP server.
Every toolset here implements pydantic-ai's
`AbstractToolset <https://ai.pydantic.dev/toolsets/>`__ interface, so it works
in any
diff --git a/providers/common/ai/provider.yaml
b/providers/common/ai/provider.yaml
index a985704dbea..814a850a20b 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -426,7 +426,7 @@ connection-types:
- 'null'
vertexai:
label: Force Vertex AI Mode
- description: "Ignored (kept for compatibility); mode is now selected
via the Model field's prefix."
+ description: "Deprecated and ignored; the Model field's prefix selects
the mode."
schema:
type:
- boolean
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
index 8a9f17ce845..3da0d71b1fd 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
@@ -36,7 +36,7 @@ from __future__ import annotations
from dataclasses import replace
from datetime import timedelta
-from airflow.providers.common.compat.sdk import dag, task
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, dag, task
try:
from airflow.providers.common.ai.policies.retry import (
@@ -181,6 +181,7 @@ try:
task_ambiguous()
example_llm_retry_policy_classifier()
-except ImportError:
- # RetryPolicy requires Airflow 3.3+; example DAG is skipped on older
versions.
+except (ImportError, AirflowOptionalProviderFeatureException):
+ # RetryPolicy requires Airflow 3.3+; policies/retry.py raises
AirflowOptionalProviderFeatureException
+ # (not an ImportError) on older versions, so the example Dag is skipped
there.
pass
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 19775160600..97364eb0c50 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
@@ -341,7 +341,7 @@ def get_provider_info():
},
"vertexai": {
"label": "Force Vertex AI Mode",
- "description": "Ignored (kept for compatibility); mode
is now selected via the Model field's prefix.",
+ "description": "Deprecated and ignored; the Model
field's prefix selects the mode.",
"schema": {"type": ["boolean", "null"]},
},
"api_key": {
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/hooks/langchain.py
b/providers/common/ai/src/airflow/providers/common/ai/hooks/langchain.py
index 1d6122359a1..8c7b37c777f 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/langchain.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/langchain.py
@@ -18,8 +18,10 @@
from __future__ import annotations
+import warnings
from typing import TYPE_CHECKING, Any
+from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.sdk import BaseHook
if TYPE_CHECKING:
@@ -68,12 +70,13 @@ class LangChainHook(BaseHook):
single hook instance.
:param llm_model: Chat model identifier in ``provider:name`` format
(e.g. ``"openai:gpt-5"``, ``"anthropic:claude-sonnet-5"``).
- Overrides ``extra["model"]`` on the connection.
+ Overrides ``extra["model"]`` on the connection. Passing it positionally
+ (1.0.0 order: ``llm_model, embed_model``) is deprecated.
:param embed_model: Embedding model identifier in ``provider:name`` format
(e.g. ``"openai:text-embedding-3-small"``). Overrides
``extra["embed_model"]`` on the connection. When ``embedding_kwargs``
supplies ``provider`` explicitly, use a model name without the provider
- prefix.
+ prefix. Passing it positionally (1.0.0 order: ``llm_model,
embed_model``) is deprecated.
:param embedding_kwargs: Additional keyword arguments to pass to the
embedding
model constructor without filtering. Values can override hook-provided
settings, including the endpoint and credentials. In particular,
@@ -96,12 +99,32 @@ class LangChainHook(BaseHook):
self,
llm_conn_id: str | None = None,
embed_conn_id: str | None = None,
+ *args: Any,
llm_model: str | None = None,
embed_model: str | None = None,
- *,
embedding_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
+ # ``*args`` only exists to keep the 1.0.0 positional order working:
``llm_model``, ``embed_model``.
+ if len(args) > 2:
+ raise TypeError(
+ f"{type(self).__name__}.__init__() takes from 1 to 5
positional arguments "
+ f"but {3 + len(args)} were given"
+ )
+ legacy_values = dict(zip(("llm_model", "embed_model"), args))
+ keyword_values = {"llm_model": llm_model, "embed_model": embed_model}
+ for name in legacy_values:
+ # A keyword of None counts as not provided, so ``name=None`` plus
a positional keeps the positional.
+ if keyword_values[name] is not None:
+ raise TypeError(f"{type(self).__name__}.__init__() got
multiple values for argument '{name}'")
+ warnings.warn(
+ f"Parameter `{name}` passed positionally is deprecated. Use
`{name}=...` instead; "
+ f"positional support will be removed in a future release.",
+ category=AirflowProviderDeprecationWarning,
+ stacklevel=2,
+ )
+ llm_model = legacy_values.get("llm_model", llm_model)
+ embed_model = legacy_values.get("embed_model", embed_model)
super().__init__(**kwargs)
# Resolve at runtime so a future subclass with its own
# `default_conn_name` is honoured. A bare `llm_conn_id: str =
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/hooks/llamaindex.py
b/providers/common/ai/src/airflow/providers/common/ai/hooks/llamaindex.py
index 6a3b01f9edd..ede87d85922 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/llamaindex.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/llamaindex.py
@@ -19,8 +19,10 @@
from __future__ import annotations
import inspect
+import warnings
from typing import TYPE_CHECKING, Any
+from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.sdk import (
AirflowOptionalProviderFeatureException,
BaseHook,
@@ -75,7 +77,8 @@ class LlamaIndexHook(BaseHook):
still fails on the model name, not on connectivity.
``get_embedding_model()`` raises immediately at construction;
``get_llm()`` defers the error until the first call that reads
- ``.metadata`` (``.chat()`` / ``.complete()``).
+ ``.metadata`` (``.chat()`` / ``.complete()``); ``test_connection()``
+ reads it, so it reports such a model name as a failure.
Connection fields:
@@ -95,10 +98,10 @@ class LlamaIndexHook(BaseHook):
embedding provider. Falls back to ``llm_conn_id`` when not set.
:param embed_model: Embedding model name (e.g.
``"text-embedding-3-small"``). Overrides ``extra["embed_model"]``
- on the connection.
+ on the connection. Passing it positionally (1.0.0 order:
``embed_model, llm_model``) is deprecated.
:param llm_model: LLM model name (e.g. ``"gpt-5"``). Overrides
``extra["llm_model"]`` on the connection. Required when calling
- :meth:`get_llm`.
+ :meth:`get_llm`. Passing it positionally (1.0.0 order: ``embed_model,
llm_model``) is deprecated.
:param embedding_kwargs: Additional keyword arguments to pass to the
embedding
model constructor without filtering. Connection ``api_key`` and
``api_base``
values take precedence at the top level, but nested options supported
by the
@@ -115,12 +118,32 @@ class LlamaIndexHook(BaseHook):
self,
llm_conn_id: str | None = None,
embed_conn_id: str | None = None,
- embed_model: str | None = None,
+ *args: Any,
llm_model: str | None = None,
- *,
+ embed_model: str | None = None,
embedding_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
+ # ``*args`` only exists to keep the 1.0.0 positional order working:
``embed_model``, ``llm_model``.
+ if len(args) > 2:
+ raise TypeError(
+ f"{type(self).__name__}.__init__() takes from 1 to 5
positional arguments "
+ f"but {3 + len(args)} were given"
+ )
+ legacy_values = dict(zip(("embed_model", "llm_model"), args))
+ keyword_values = {"embed_model": embed_model, "llm_model": llm_model}
+ for name in legacy_values:
+ # A keyword of None counts as not provided, so ``name=None`` plus
a positional keeps the positional.
+ if keyword_values[name] is not None:
+ raise TypeError(f"{type(self).__name__}.__init__() got
multiple values for argument '{name}'")
+ warnings.warn(
+ f"Parameter `{name}` passed positionally is deprecated. Use
`{name}=...` instead; "
+ f"positional support will be removed in a future release.",
+ category=AirflowProviderDeprecationWarning,
+ stacklevel=2,
+ )
+ embed_model = legacy_values.get("embed_model", embed_model)
+ llm_model = legacy_values.get("llm_model", llm_model)
super().__init__(**kwargs)
# Resolve at runtime so a future per-vendor subclass with its own
# ``default_conn_name`` is honoured.
@@ -233,15 +256,16 @@ class LlamaIndexHook(BaseHook):
def test_connection(self) -> tuple[bool, str]:
"""
- Test connection by resolving the LLM.
+ Test connection by resolving the LLM and reading its metadata.
- Validates that the model identifier is valid and the provider can be
- instantiated with the supplied credentials. Does NOT make an LLM API
- call -- that would be expensive and fail for reasons unrelated to
- connectivity (quotas, billing, rate limits).
+ Reading ``.metadata`` makes LlamaIndex validate the model name against
its
+ OpenAI-only allowlist, so an Ollama or vLLM model name fails here
instead of at the
+ first ``.chat()`` call. Does NOT make an LLM API call -- that would be
expensive and
+ fail for reasons unrelated to connectivity (quotas, billing, rate
limits), so a
+ revoked API key or an unreachable **host** still passes.
"""
try:
- self.get_llm()
+ self.get_llm().metadata
return True, "Model resolved successfully."
except Exception as e:
return False, str(e)
diff --git a/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
b/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
index 46c81d3fcc0..810b0fccbc4 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
@@ -16,9 +16,14 @@
# under the License.
from __future__ import annotations
+import json
from typing import TYPE_CHECKING, Any
-from airflow.providers.common.compat.sdk import BaseHook, mask_secret
+from airflow.providers.common.compat.sdk import (
+ AirflowOptionalProviderFeatureException,
+ BaseHook,
+ mask_secret,
+)
if TYPE_CHECKING:
from collections.abc import Callable
@@ -203,10 +208,10 @@ class MCPHook(BaseHook):
try:
from fastmcp.client.transports import SSETransport,
StdioTransport, StreamableHttpTransport
from pydantic_ai.mcp import MCPToolset
- except ImportError:
- raise ImportError(
+ except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(
'MCP support requires the `mcp` package. Install it with: pip
install "pydantic-ai-slim[mcp]"'
- )
+ ) from e
conn = self.get_connection(self.mcp_conn_id)
extra = conn.extra_dejson
@@ -226,13 +231,47 @@ class MCPHook(BaseHook):
raise ValueError(
f"Connection {self.mcp_conn_id!r} requires 'command' in
extra for stdio transport."
)
- args = extra.get("args", [])
- if isinstance(args, str):
- args = [args]
+ args = extra.get("args")
+ if args is None:
+ args = []
+ elif isinstance(args, str):
+ if args.strip().startswith("["):
+ try:
+ args = json.loads(args)
+ except ValueError as e:
+ raise ValueError(
+ f"'args' in extra for connection
{self.mcp_conn_id!r} looks like a JSON array "
+ "but is not valid JSON."
+ ) from e
+ else:
+ args = [args]
+ if not isinstance(args, list):
+ raise ValueError(
+ f"'args' in extra for connection {self.mcp_conn_id!r} must
be a string, a JSON "
+ f"array of strings or a list of strings, got
{type(args).__name__}."
+ )
+ for index, arg in enumerate(args):
+ if not isinstance(arg, str):
+ raise ValueError(
+ f"'args' in extra for connection {self.mcp_conn_id!r}
must contain only strings, "
+ f"got {type(arg).__name__} at index {index}."
+ )
timeout = extra.get("timeout", 10)
+ # ``None`` and ``0`` are both valid "no timeout" values, but
fastmcp replaces
+ # ``init_timeout=None`` with its global ``client_init_timeout``
setting, so ``None``
+ # is mapped to ``0`` to keep ``FASTMCP_CLIENT_INIT_TIMEOUT`` from
re-enabling a timeout.
+ if timeout is not None and (
+ isinstance(timeout, bool)
+ or not isinstance(timeout, (int, float))
+ or not 0 <= timeout < float("inf")
+ ):
+ raise ValueError(
+ f"'timeout' in extra for connection {self.mcp_conn_id!r}
must be a non-negative "
+ f"finite number of seconds, got {timeout!r}."
+ )
toolset = MCPToolset(
StdioTransport(command=command, args=args,
env=self._stdio_env(extra)),
- init_timeout=timeout,
+ init_timeout=0 if timeout is None else timeout,
)
else:
raise ValueError(
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 146560881e4..741dfe5a30d 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
@@ -25,7 +25,7 @@ from pydantic_ai import Agent
from pydantic_ai.exceptions import ModelAPIError
from pydantic_ai.models import infer_model
from pydantic_ai.models.fallback import FallbackModel
-from pydantic_ai.providers import infer_provider, infer_provider_class
+from pydantic_ai.providers import infer_provider_class
from airflow.providers.common.ai.observability import
genai_instrumentation_settings
from airflow.providers.common.ai.utils.toolset_base import MaskingCapability
@@ -451,13 +451,12 @@ class PydanticAIHook(BaseHook):
def _provider_factory(pname: str) -> Any:
try:
return infer_provider_class(pname)(**_kwargs)
- except TypeError:
- self.log.warning(
- "Provider '%s' rejected kwargs %s; falling back to
env-var auth",
- pname,
- list(_kwargs),
- )
- return infer_provider(pname)
+ except TypeError as e:
+ raise ValueError(
+ f"Could not build provider '{pname}' from the fields
on connection "
+ f"'{self.llm_conn_id}' (fields passed: {',
'.join(_kwargs)}): {e}. "
+ "Not falling back to environment-variable auth, which
would authenticate as a different identity."
+ ) from e
return infer_model(model_name, provider_factory=_provider_factory)
@@ -678,11 +677,10 @@ class PydanticAIHook(BaseHook):
"""
Test connection by resolving the model.
- A success here can come from this connection's own credentials, or --
when a
- provider class rejects them with a ``TypeError`` -- from a silent
retry against
- the standard environment variables, which ignores those credentials
entirely.
- See :doc:`/provider_fallback`'s *Verifying a chain* section for how to
tell the
- two apart. Does NOT make an LLM API call — that would be expensive and
fail for
+ A provider class that cannot be built from the connection's fields is
reported as a failure
+ naming the provider, the fields passed and the provider's
``TypeError`` text.
+
+ Does NOT make an LLM API call — that would be expensive and fail for
reasons unrelated to connectivity (quotas, billing, rate limits).
Every connection in ``fallback_conn_ids`` is resolved too, so a
@@ -691,9 +689,9 @@ class PydanticAIHook(BaseHook):
"""
try:
self.get_conn()
- return True, "Model resolved successfully."
except Exception as e:
return False, str(e)
+ return True, "Model resolved successfully."
class PydanticAIAzureHook(PydanticAIHook):
@@ -848,7 +846,7 @@ class PydanticAIVertexHook(PydanticAIHook):
1. ``service_account_info`` (JSON object) in ``extra``
— loaded into a ``google.auth.credentials.Credentials``
- object and passed as ``credentials`` to ``GoogleProvider``.
+ object and passed as ``credentials`` to ``GoogleCloudProvider``.
2. ``api_key`` in ``extra`` — for Generative Language API (non-Vertex) or
Vertex API-key auth.
3. Application Default Credentials (``GOOGLE_APPLICATION_CREDENTIALS``,
@@ -880,8 +878,11 @@ class PydanticAIVertexHook(PydanticAIHook):
above), so its presence alone cannot tell the two platforms apart; guessing
would risk silently authenticating against the wrong endpoint. To use the
Generative Language API, set an explicit ``google:``-prefixed model id (on
- the hook or the connection's ``model`` extra) -- that spelling already
- works today.
+ the hook or the connection's ``model`` extra). ``GoogleProvider`` accepts
only
+ ``api_key`` and ``base_url`` (without a key it reads ``GOOGLE_API_KEY``),
so on
+ this connection type that spelling must not set Vertex-only fields
+ (``project``, ``location``, ``service_account_info``): they are rejected
+ (fail closed).
:param llm_conn_id: Airflow connection ID.
:param model_id: Model identifier, e.g.
``"google-cloud:gemini-2.5-flash"``. A
@@ -919,7 +920,7 @@ class PydanticAIVertexHook(PydanticAIHook):
sa_info = extra.get("service_account_info")
kwargs: dict[str, Any] = {}
- # Direct GoogleProvider scalar kwargs.
+ # Scalar kwargs: GoogleCloudProvider takes all four; GoogleProvider
only api_key/base_url.
for _key in ("api_key", "project", "location", "base_url"):
if extra.get(_key):
kwargs[_key] = extra[_key]
@@ -927,11 +928,10 @@ class PydanticAIVertexHook(PydanticAIHook):
# "vertexai" predates pydantic-ai splitting GoogleProvider (Generative
Language API)
# from GoogleCloudProvider (Vertex AI, which hardcodes vertexai=True
internally and
# accepts no such constructor kwarg) in pydantic/pydantic-ai#5336.
Forwarding it would
- # raise TypeError, which the base hook's `except TypeError` in
get_conn() would then
- # catch: it logs a warning and falls back to env-var auth rather than
raising --
- # authenticating as the wrong identity (with *all* other kwargs
discarded). Accept
- # the field for backward compatibility but never forward it: which API
is used is
- # now controlled by the model prefix.
+ # raise TypeError, which the base hook's `except TypeError` in
get_conn() would turn
+ # into a ValueError rejecting the whole connection. Accept the field
for backward
+ # compatibility but never forward it: which API is used is now
controlled by the
+ # model prefix.
if extra.get("vertexai") is not None:
self.log.warning(
"The 'vertexai' connection field is ignored; Vertex AI vs.
Generative Language "
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
b/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
index 637679b1177..4f14db9dd0d 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
@@ -57,6 +57,7 @@ from airflow.providers.common.ai.utils.decision import (
threshold_for,
)
from airflow.providers.common.ai.utils.masking import mask_secrets
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
try:
from airflow.sdk.definitions.retry_policy import (
@@ -66,7 +67,7 @@ try:
RetryPolicy,
)
except ImportError:
- raise ImportError(
+ raise AirflowOptionalProviderFeatureException(
"LLMRetryPolicy requires Airflow 3.3+ which includes RetryPolicy
support. "
"Please upgrade apache-airflow-core."
) from None
@@ -317,7 +318,7 @@ class _ModelRetryPolicy(RetryPolicy):
def _hook(self) -> PydanticAIHook:
from airflow.providers.common.ai.hooks.pydantic_ai import
PydanticAIHook
- return PydanticAIHook(llm_conn_id=self.llm_conn_id,
model_id=self.model_id)
+ return PydanticAIHook.get_hook(self.llm_conn_id,
hook_params={"model_id": self.model_id})
def _prompt(self, exception: BaseException, try_number: int, max_tries:
int) -> str:
if self.include_traceback:
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_langchain.py
b/providers/common/ai/tests/unit/common/ai/hooks/test_langchain.py
index 58fb66f1bf2..17feedcdca1 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_langchain.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_langchain.py
@@ -17,10 +17,12 @@
from __future__ import annotations
import sys
+import warnings
from unittest.mock import MagicMock, patch
import pytest
+from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.ai.hooks.langchain import LangChainHook
@@ -72,6 +74,78 @@ class TestLangChainHookInit:
assert hook.llm_conn_id == "my_conn"
assert hook.embed_conn_id == "my_conn"
+ @pytest.mark.parametrize(
+ ("positional", "expected"),
+ [
+ pytest.param(("llm-pos",), {"llm_model": "llm-pos", "embed_model":
None}, id="one"),
+ pytest.param(
+ ("llm-pos", "embed-pos"),
+ {"llm_model": "llm-pos", "embed_model": "embed-pos"},
+ id="two",
+ ),
+ ],
+ )
+ def test_positional_model_emits_deprecation_warning(self, positional,
expected):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ hook = LangChainHook("c1", "c2", *positional)
+ assert len(record) == len(positional)
+ assert "Parameter `llm_model` passed positionally is deprecated" in
str(record[0].message)
+ assert hook.llm_conn_id == "c1"
+ assert hook.embed_conn_id == "c2"
+ assert hook.llm_model == expected["llm_model"]
+ assert hook.embed_model == expected["embed_model"]
+
+ def test_positional_warning_stacklevel_points_to_caller(self):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ LangChainHook("c1", "c2", "llm-pos")
+ assert record[0].filename == __file__
+
+ def test_one_warning_per_positional_argument(self):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ LangChainHook("c1", "c2", "llm-pos", "embed-pos")
+ messages = [str(w.message) for w in record]
+ assert len(messages) == 2
+ assert "`llm_model`" in messages[0]
+ assert "`embed_model`" in messages[1]
+
+ def test_keyword_model_arguments_do_not_warn(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+ hook = LangChainHook("c1", "c2", llm_model="llm-pos",
embed_model="embed-pos")
+ assert hook.llm_model == "llm-pos"
+ assert hook.embed_model == "embed-pos"
+
+ def test_connection_positional_arguments_do_not_warn(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+ hook = LangChainHook("c1", "c2")
+ assert hook.embed_model is None
+ assert hook.llm_model is None
+
+ def test_too_many_positional_arguments_raise_type_error(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+ with pytest.raises(TypeError, match="positional argument"):
+ LangChainHook("c1", "c2", "llm-pos", "embed-pos", "extra")
+
+ def test_positional_and_keyword_same_parameter_raise_type_error(self):
+ with pytest.raises(TypeError, match="multiple values for argument
'llm_model'"):
+ LangChainHook("c1", "c2", "llm-pos", llm_model="other")
+
+ def
test_second_positional_and_keyword_same_parameter_raise_type_error(self):
+ with (
+ pytest.warns(AirflowProviderDeprecationWarning),
+ pytest.raises(TypeError, match="multiple values for argument
'embed_model'"),
+ ):
+ LangChainHook("c1", "c2", "L", "E", embed_model="X")
+
+ def test_positional_and_keyword_different_parameters_combine(self):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ hook = LangChainHook("c1", "c2", "llm-pos",
embed_model="embed-pos")
+ assert len(record) == 1
+ assert hook.llm_model == "llm-pos"
+ assert hook.embed_model == "embed-pos"
+
def test_explicit_separate_conns_and_models(self):
hook = LangChainHook(
llm_conn_id="chat_conn",
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_llamaindex.py
b/providers/common/ai/tests/unit/common/ai/hooks/test_llamaindex.py
index ac0fcfd71cb..3274519629c 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_llamaindex.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_llamaindex.py
@@ -16,10 +16,12 @@
# under the License.
from __future__ import annotations
+import warnings
from unittest.mock import MagicMock, patch
import pytest
+from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.ai.hooks.llamaindex import LlamaIndexHook
@@ -56,6 +58,78 @@ class TestLlamaIndexHookInit:
assert hook.embed_model == "text-embedding-3-large"
assert hook.llm_model == "gpt-4o"
+ @pytest.mark.parametrize(
+ ("positional", "expected"),
+ [
+ pytest.param(("embed-pos",), {"embed_model": "embed-pos",
"llm_model": None}, id="one"),
+ pytest.param(
+ ("embed-pos", "llm-pos"),
+ {"embed_model": "embed-pos", "llm_model": "llm-pos"},
+ id="two",
+ ),
+ ],
+ )
+ def test_positional_model_emits_deprecation_warning(self, positional,
expected):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ hook = LlamaIndexHook("c1", "c2", *positional)
+ assert len(record) == len(positional)
+ assert "Parameter `embed_model` passed positionally is deprecated" in
str(record[0].message)
+ assert hook.llm_conn_id == "c1"
+ assert hook.embed_conn_id == "c2"
+ assert hook.embed_model == expected["embed_model"]
+ assert hook.llm_model == expected["llm_model"]
+
+ def test_positional_warning_stacklevel_points_to_caller(self):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ LlamaIndexHook("c1", "c2", "embed-pos")
+ assert record[0].filename == __file__
+
+ def test_one_warning_per_positional_argument(self):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ LlamaIndexHook("c1", "c2", "embed-pos", "llm-pos")
+ messages = [str(w.message) for w in record]
+ assert len(messages) == 2
+ assert "`embed_model`" in messages[0]
+ assert "`llm_model`" in messages[1]
+
+ def test_keyword_model_arguments_do_not_warn(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+ hook = LlamaIndexHook("c1", "c2", embed_model="embed-pos",
llm_model="llm-pos")
+ assert hook.embed_model == "embed-pos"
+ assert hook.llm_model == "llm-pos"
+
+ def test_connection_positional_arguments_do_not_warn(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+ hook = LlamaIndexHook("c1", "c2")
+ assert hook.embed_model is None
+ assert hook.llm_model is None
+
+ def test_too_many_positional_arguments_raise_type_error(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+ with pytest.raises(TypeError, match="positional argument"):
+ LlamaIndexHook("c1", "c2", "embed-pos", "llm-pos", "extra")
+
+ def test_positional_and_keyword_same_parameter_raise_type_error(self):
+ with pytest.raises(TypeError, match="multiple values for argument
'embed_model'"):
+ LlamaIndexHook("c1", "c2", "embed-pos", embed_model="other")
+
+ def
test_second_positional_and_keyword_same_parameter_raise_type_error(self):
+ with (
+ pytest.warns(AirflowProviderDeprecationWarning),
+ pytest.raises(TypeError, match="multiple values for argument
'llm_model'"),
+ ):
+ LlamaIndexHook("c1", "c2", "E", "L", llm_model="X")
+
+ def test_positional_and_keyword_different_parameters_combine(self):
+ with pytest.warns(AirflowProviderDeprecationWarning) as record:
+ hook = LlamaIndexHook("c1", "c2", "embed-pos", llm_model="llm-pos")
+ assert len(record) == 1
+ assert hook.embed_model == "embed-pos"
+ assert hook.llm_model == "llm-pos"
+
def test_conn_type_is_llamaindex(self):
assert LlamaIndexHook.conn_type == "llamaindex"
assert LlamaIndexHook.default_conn_name == "llamaindex_default"
@@ -266,6 +340,16 @@ class TestConnectionTest:
assert success is False
assert "Invalid API key" in message
+ @patch.object(LlamaIndexHook, "get_connection")
+ def test_non_openai_model_name_fails(self, mock_get_conn):
+ """The real ``OpenAI`` class only rejects a model name once
``.metadata`` is read."""
+ mock_get_conn.return_value = _conn(password="sk-test",
extra={"llm_model": "llama3.2"})
+
+ success, message = LlamaIndexHook().test_connection()
+
+ assert success is False
+ assert "Unknown model 'llama3.2'" in message
+
@patch.object(LlamaIndexHook, "get_connection")
def test_failed_connection_no_model(self, mock_get_conn):
mock_get_conn.return_value = _conn()
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
b/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
index d226d585dd7..d0efd7f64dd 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
@@ -17,12 +17,14 @@
from __future__ import annotations
import json
+import sys
from unittest.mock import MagicMock, patch
import pytest
from airflow.models.connection import Connection
from airflow.providers.common.ai.hooks.mcp import MCPHook
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
# The hook imports these lazily inside get_conn(), so we patch them at their
# source modules rather than on the hook module. MCPToolset is the unified
@@ -178,6 +180,31 @@ class TestMCPHookGetConn:
mock_transport_cls.assert_called_once_with(command="uvx",
args=["mcp-run-python"], env=None)
+ @pytest.mark.parametrize(
+ ("raw_args", "expected_args"),
+ [
+ (None, []),
+ ('["mcp-run-python"]', ["mcp-run-python"]),
+ (' ["-m", "server"]', ["-m", "server"]),
+ ],
+ ids=["null", "json_array_string", "json_array_string_leading_space"],
+ )
+ @patch(_MCP_TOOLSET, autospec=True)
+ @patch(_STDIO_TRANSPORT, autospec=True)
+ def test_args_from_connection_form_shapes(
+ self, mock_transport_cls, mock_toolset_cls, raw_args, expected_args
+ ):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
raw_args}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ hook.get_conn()
+
+ mock_transport_cls.assert_called_once_with(command="uvx",
args=expected_args, env=None)
+
def test_http_without_host_raises(self):
hook = MCPHook(mcp_conn_id="test_conn")
conn = Connection(conn_id="test_conn", conn_type="mcp")
@@ -207,6 +234,107 @@ class TestMCPHookGetConn:
with pytest.raises(ValueError, match="requires 'command'"):
hook.get_conn()
+ def test_missing_mcp_dependency_raises_optional_feature_exception(self):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ with patch.dict(sys.modules, {"fastmcp.client.transports": None}):
+ with pytest.raises(AirflowOptionalProviderFeatureException,
match="pydantic-ai-slim"):
+ hook.get_conn()
+
+ @pytest.mark.parametrize(
+ "bad_args",
+ [{"a": 1}, ["-m", 1], 5, '["-m", 1]', '["-m"', '["-m"] trailing'],
+ ids=["dict", "non_string_item", "int", "json_non_string_item",
"malformed_json", "trailing_text"],
+ )
+ def test_stdio_invalid_args_raises(self, bad_args):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
bad_args}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ with pytest.raises(ValueError, match="'args' in extra"):
+ hook.get_conn()
+
+ @pytest.mark.parametrize(
+ "bad_args",
+ [["--header", "Authorization: Bearer s3cr3t", 1], '["--header",
"Bearer s3cr3t", 1]'],
+ ids=["list", "json_string"],
+ )
+ def
test_stdio_non_string_arg_error_reports_type_and_index_not_values(self,
bad_args):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
bad_args}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ with pytest.raises(ValueError, match=r"int at index 2") as
exc_info:
+ hook.get_conn()
+ assert "s3cr3t" not in str(exc_info.value)
+
+ def test_stdio_non_list_args_error_does_not_echo_value(self):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
{"token": "s3cr3t"}}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ with pytest.raises(ValueError, match="got dict") as exc_info:
+ hook.get_conn()
+ assert "s3cr3t" not in str(exc_info.value)
+
+ def test_stdio_invalid_json_args_error_does_not_echo_value(self):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
'["Bearer s3cr3t"'}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ with pytest.raises(ValueError, match="looks like a JSON array") as
exc_info:
+ hook.get_conn()
+ assert "s3cr3t" not in str(exc_info.value)
+
+ @pytest.mark.parametrize(
+ "bad_timeout",
+ ["30", -5, -0.5, float("nan"), float("inf"), True, [10]],
+ ids=["str", "neg", "neg_float", "nan", "inf", "bool", "list"],
+ )
+ def test_stdio_invalid_timeout_raises(self, bad_timeout):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
[], "timeout": bad_timeout}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ with pytest.raises(ValueError, match="'timeout' in extra"):
+ hook.get_conn()
+
+ @pytest.mark.parametrize(
+ ("timeout", "expected_init_timeout"),
+ [(2.5, 2.5), (0, 0), (0.0, 0.0), (None, 0)],
+ ids=["float", "zero", "zero_float", "null_maps_to_zero"],
+ )
+ @patch(_MCP_TOOLSET, autospec=True)
+ @patch(_STDIO_TRANSPORT, autospec=True)
+ def test_stdio_float_zero_and_null_timeout_accepted(
+ self, mock_transport_cls, mock_toolset_cls, timeout,
expected_init_timeout
+ ):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ extra=json.dumps({"transport": "stdio", "command": "uvx", "args":
[], "timeout": timeout}),
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ hook.get_conn()
+ mock_toolset_cls.assert_called_once_with(
+ mock_transport_cls.return_value, init_timeout=expected_init_timeout
+ )
+
def test_unknown_transport_raises(self):
hook = MCPHook(mcp_conn_id="test_conn")
conn = Connection(
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
index 2340189bb91..d86365ed00a 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
@@ -1561,6 +1561,20 @@ class TestPydanticAIHookCreateAgentInstrumentation:
mock_settings.assert_not_called()
+class _RejectsKwargsProvider:
+ """Stand-in for a provider class whose constructor takes none of the
connection's fields."""
+
+ def __init__(self):
+ pass
+
+
+def _call_provider_factory(model_name, provider_factory=None):
+ """Stand-in for ``infer_model`` that, like the real one, builds the
provider eagerly."""
+ if provider_factory is not None:
+ provider_factory(model_name.split(":", 1)[0])
+ return MagicMock(spec=Model)
+
+
class TestPydanticAIHookTestConnection:
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model",
autospec=True)
def test_successful_connection(self, mock_infer_model):
@@ -1610,6 +1624,52 @@ class TestPydanticAIHookTestConnection:
assert success is False
assert "No model specified" in message
+
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class",
autospec=True)
+ @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model",
autospec=True)
+ def test_get_conn_raises_when_provider_class_cannot_be_built(
+ self, mock_infer_model, mock_infer_provider_class, registry
+ ):
+ mock_infer_provider_class.return_value = _RejectsKwargsProvider
+ mock_infer_model.side_effect = _call_provider_factory
+ registry.add("primary", password="sk-key", extra={"model":
"openai:gpt-5.6-sol"})
+
+ with pytest.raises(ValueError, match="Could not build provider") as
exc_info:
+ PydanticAIHook(llm_conn_id="primary").get_conn()
+
+ assert isinstance(exc_info.value.__cause__, TypeError)
+
+ @pytest.mark.parametrize(
+ ("fallback_conn_ids", "expected_conn_id"),
+ [
+ pytest.param(None, "primary", id="primary"),
+ pytest.param(["second"], "second", id="fallback_connection"),
+ ],
+ )
+
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class",
autospec=True)
+ @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model",
autospec=True)
+ def test_reports_provider_class_build_failure(
+ self, mock_infer_model, mock_infer_provider_class, registry,
fallback_conn_ids, expected_conn_id
+ ):
+ mock_infer_provider_class.return_value = _RejectsKwargsProvider
+ mock_infer_model.side_effect = _call_provider_factory
+ registry.add(
+ "primary",
+ password="sk-key" if fallback_conn_ids is None else None,
+ extra={"model": "openai:gpt-5.6-sol"},
+ )
+ registry.add("second", password="sk-key", extra={"model":
"openai:gpt-5.6-sol"})
+
+ success, message = PydanticAIHook(
+ llm_conn_id="primary", fallback_conn_ids=fallback_conn_ids
+ ).test_connection()
+
+ assert success is False
+ assert (
+ f"Could not build provider 'openai' from the fields on connection
'{expected_conn_id}'" in message
+ )
+ assert "(fields passed: api_key)" in message
+ assert "unexpected keyword argument 'api_key'" in message
+
# ---------------------------------------------------------------------------
# Subclass hook tests
@@ -2020,13 +2080,12 @@ class TestPydanticAIVertexHook:
mock_provider_cls.assert_called_with(project="my-project",
location="europe-west4")
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model",
autospec=True)
- @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider",
autospec=True)
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class",
autospec=True)
- def test_get_conn_vertexai_flag_does_not_trigger_typeerror_fallback(
- self, mock_infer_provider_class, mock_infer_provider, mock_infer_model
+ def test_get_conn_vertexai_flag_does_not_trigger_typeerror_rejection(
+ self, mock_infer_provider_class, mock_infer_model
):
- """Setting ``vertexai`` must not push ``get_conn`` onto the ``except
TypeError``
- fallback path, which would silently discard
project/location/credentials.
+ """Setting ``vertexai`` must not make the provider class reject the
connection's
+ kwargs with ``TypeError``, which ``get_conn`` turns into a
``ValueError``.
The stand-in below has the exact keyword-only signature of the real
``GoogleCloudProvider.__init__`` (verified against the installed
pydantic-ai) so
@@ -2080,8 +2139,6 @@ class TestPydanticAIVertexHook:
assert isinstance(provider, FakeGoogleCloudProvider)
assert provider.kwargs["project"] == "my-project"
assert provider.kwargs["location"] == "us-central1"
- # The TypeError fallback must never have been reached.
- mock_infer_provider.assert_not_called()
def test_documented_model_prefix_is_a_valid_pydantic_ai_provider(self):
"""Regression test: the model-prefix documented in the connection form
and
diff --git a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
index a317988efe8..33e638eeb4d 100644
--- a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
+++ b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
@@ -17,10 +17,12 @@
from __future__ import annotations
import copy
+import importlib.util
import json
import logging
import math
import re
+import sys
import traceback
import warnings
from datetime import timedelta
@@ -37,6 +39,9 @@ from pydantic_ai.messages import ModelResponse
# Skip the entire test module on older Airflow versions tested in compat CI.
pytest.importorskip("airflow.sdk.definitions.retry_policy",
reason="RetryPolicy requires Airflow 3.3+")
+from airflow.models.connection import Connection
+from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIAzureHook,
PydanticAIHook
+from airflow.providers.common.ai.policies import retry as retry_module
from airflow.providers.common.ai.policies.retry import (
CLASSIFIER_INSTRUCTIONS,
DEFAULT_CATEGORIES,
@@ -48,6 +53,7 @@ from airflow.providers.common.ai.policies.retry import (
redact_registered_secrets,
)
from airflow.providers.common.ai.utils.decision import picked_key
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
from airflow.sdk._shared.secrets_masker import reset_secrets_masker
from airflow.sdk.definitions.retry_policy import RetryAction, RetryDecision,
RetryPolicy, RetryRule
from airflow.sdk.log import mask_secret
@@ -79,14 +85,21 @@ def _agent(output, **kwargs):
return agent
+def _stub_hook(mock_hook_cls):
+ """Make ``get_hook`` return a hook instance that keeps the real method
signatures."""
+ hook = create_autospec(PydanticAIHook, instance=True)
+ mock_hook_cls.get_hook.return_value = hook
+ return hook
+
+
def _install(mock_hook_cls, agent):
"""Wire a mock agent behind the patched hook class and return the mock
agent."""
- mock_hook_cls.return_value.create_agent.return_value = agent
+ _stub_hook(mock_hook_cls).create_agent.return_value = agent
return agent
def _output_type(mock_hook_cls):
- return
mock_hook_cls.return_value.create_agent.call_args.kwargs["output_type"]
+ return
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs["output_type"]
def _options(output_type):
@@ -109,6 +122,15 @@ def
test_redact_registered_secrets_masks_only_registered_values():
)
+def
test_import_without_retry_policy_support_raises_optional_feature_exception():
+ """A fresh copy of the module is executed so the real one in
``sys.modules`` stays intact."""
+ spec = importlib.util.spec_from_file_location("_retry_without_support",
retry_module.__file__)
+ module = importlib.util.module_from_spec(spec)
+ with patch.dict(sys.modules, {"airflow.sdk.definitions.retry_policy":
None}):
+ with pytest.raises(AirflowOptionalProviderFeatureException,
match="Airflow 3.3"):
+ spec.loader.exec_module(module)
+
+
class TestErrorCategory:
@pytest.mark.parametrize("description", ["", " ", None, 3])
def test_description_must_be_a_non_empty_string(self, description):
@@ -177,7 +199,20 @@ class TestClassifierRetryPolicyConstruction:
min_confidence=0.6,
)
- mock_hook_cls.assert_not_called()
+ mock_hook_cls.get_hook.assert_not_called()
+
+ def test_hook_resolves_to_the_connection_types_subclass(self):
+ """An Azure connection must get ``PydanticAIAzureHook``, not the base
hook's ``base_url`` kwargs."""
+ policy = ClassifierRetryPolicy(
+ llm_conn_id="azure_conn", model_id="azure:gpt-5",
categories=DEFAULT_CATEGORIES
+ )
+ conn = Connection(conn_id="azure_conn", conn_type="pydanticai_azure")
+
+ with patch.object(PydanticAIHook, "get_connection", return_value=conn):
+ hook = policy._hook()
+
+ assert isinstance(hook, PydanticAIAzureHook)
+ assert hook.model_id == "azure:gpt-5"
def test_categories_must_be_a_mapping(self):
with pytest.raises(TypeError, match="categories must be a mapping"):
@@ -366,7 +401,7 @@ class TestClassification:
agent.run_sync.return_value =
_run_result(kwargs["output_type"]("rate_limit"))
return agent
- mock_hook_cls.return_value.create_agent.side_effect = capture
+ _stub_hook(mock_hook_cls).create_agent.side_effect = capture
policy = ClassifierRetryPolicy(llm_conn_id="test",
categories=DEFAULT_CATEGORIES)
decision = policy.evaluate(RuntimeError("429"), try_number=1,
max_tries=3)
@@ -391,7 +426,7 @@ class TestClassification:
agent.run_sync.return_value =
_run_result(kwargs["output_type"](name))
return agent
- mock_hook_cls.return_value.create_agent.side_effect = capture
+ _stub_hook(mock_hook_cls).create_agent.side_effect = capture
policy = ClassifierRetryPolicy(
llm_conn_id="test",
categories={
@@ -489,7 +524,7 @@ class TestClassification:
policy.evaluate(RuntimeError("x"), try_number=1, max_tries=3)
assert (
-
mock_hook_cls.return_value.create_agent.call_args.kwargs["instructions"]
+
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs["instructions"]
== "Snowflake errors only."
)
@@ -502,7 +537,9 @@ class TestClassification:
policy.evaluate(RuntimeError("x"), try_number=1, max_tries=3)
- mock_hook_cls.assert_called_once_with(llm_conn_id="jev_default",
model_id="typesafe:jev-1.13.0")
+ mock_hook_cls.get_hook.assert_called_once_with(
+ "jev_default", hook_params={"model_id": "typesafe:jev-1.13.0"}
+ )
@patch(HOOK, autospec=True)
def test_timeout_passed_via_model_settings(self, mock_hook_cls):
@@ -971,7 +1008,7 @@ class TestFallbackBehaviour:
@patch(HOOK, autospec=True)
def test_hook_creation_failure_triggers_fallback(self, mock_hook_cls):
- mock_hook_cls.return_value.create_agent.side_effect =
RuntimeError("unexpected")
+ _stub_hook(mock_hook_cls).create_agent.side_effect =
RuntimeError("unexpected")
policy = ClassifierRetryPolicy(
llm_conn_id="test",
fallback_rules=[RetryRule(exception=ValueError,
action=RetryAction.FAIL, reason="caught")],
@@ -1058,7 +1095,7 @@ class TestOnUncertain:
def create_agent(**kwargs):
return llm if kwargs["output_type"] is ErrorClassification else
classifier
- mock_hook_cls.return_value.create_agent.side_effect = create_agent
+ _stub_hook(mock_hook_cls).create_agent.side_effect = create_agent
return ClassifierRetryPolicy(
llm_conn_id="jev",
min_confidence=bar,
@@ -1097,7 +1134,7 @@ class TestOnUncertain:
assert decision.action == RetryAction.RETRY
assert decision.reason == "category=network confidence=0.95
threshold=0.80 action=retry delay=10s"
- assert mock_hook_cls.return_value.create_agent.call_count == 1
+ assert mock_hook_cls.get_hook.return_value.create_agent.call_count == 1
@patch(HOOK, autospec=True)
def test_unsure_classifier_escalates_to_the_llm(self, mock_hook_cls):
@@ -1256,7 +1293,7 @@ class TestOnUncertain:
@patch(HOOK, autospec=True)
def
test_nested_classifiers_both_down_still_reach_the_outer_fail_rule(self,
mock_hook_cls):
"""A ClassifierRetryPolicy as fallback_policy falls back with a
DEFAULT of its own; the outer rules must still run."""
-
mock_hook_cls.return_value.create_agent.return_value.run_sync.side_effect =
TimeoutError("down")
+
_stub_hook(mock_hook_cls).create_agent.return_value.run_sync.side_effect =
TimeoutError("down")
inner = ClassifierRetryPolicy(llm_conn_id="jev_b", min_confidence=0.5)
policy = ClassifierRetryPolicy(
llm_conn_id="jev_a",
@@ -1271,7 +1308,7 @@ class TestOnUncertain:
assert decision.action == RetryAction.FAIL
assert decision.reason == "classifier answer not applied
(model_error); never retry 403"
- assert mock_hook_cls.return_value.create_agent.call_count == 2
+ assert mock_hook_cls.get_hook.return_value.create_agent.call_count == 2
def _open_agent(category, should_retry, delay=0, reasoning="test"):
@@ -1316,7 +1353,7 @@ class TestLLMRetryPolicy:
_install(mock_hook_cls, _open_agent("auth", should_retry=False))
LLMRetryPolicy(llm_conn_id="test").evaluate(PermissionError("403"),
try_number=1, max_tries=3)
- kwargs = mock_hook_cls.return_value.create_agent.call_args.kwargs
+ kwargs =
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs
assert kwargs["output_type"] is ErrorClassification
assert kwargs["instructions"] == DEFAULT_INSTRUCTIONS
@@ -1368,7 +1405,7 @@ class TestLLMRetryPolicy:
assert decision.action == RetryAction.RETRY
assert decision.retry_delay == timedelta(seconds=30)
assert decision.reason == "warehouse_suspended: auto-resume"
- assert
mock_hook_cls.return_value.create_agent.call_args.kwargs["instructions"].startswith(
+ assert
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs["instructions"].startswith(
"'Warehouse suspended'"
)
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 16a3aa58232..400829ba0ff 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
@@ -132,15 +132,16 @@ DEPRECATED_UPSTREAM_MODULES = {"github"}
# 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.
+# ``TypeError: unexpected keyword argument 'api_key'``.
`PydanticAIHook.get_conn()`
+# re-raises that `TypeError` as a `ValueError` when the connection has a
password or host. Only
+# a connection with neither falls through to `infer_model(model_name)`, 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
+# the ambient Codex CLI login can, with no password/host set. 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
diff --git
a/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_model.py
b/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_model.py
index 8371c9505f8..c3fcbaf937f 100644
--- a/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_model.py
+++ b/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_model.py
@@ -65,8 +65,8 @@ class TestGetProviderKwargs:
def test_ignores_connection_password_and_omits_api_key(self,
mock_hook_cls, mock_provider_cls):
"""Regression: a leftover Password on the pydanticai_snowflake
connection must not be
forwarded as api_key -- the base hook's generic handling raises
TypeError against
- SnowflakeProvider (which takes `token`, not `api_key`) and silently
falls back to
- env-var auth; this hook's own kwargs must never trigger that path."""
+ SnowflakeProvider (which takes `token`, not `api_key`), which fails
the connection
+ with a ValueError; this hook's own kwargs must never trigger that
path."""
mock_hook_cls.return_value._get_static_conn_params = {"account":
"airflow"}
hook = PydanticAISnowflakeHook(snowflake_conn_id="snowflake_conn")
@@ -248,17 +248,14 @@ class TestSnowflakeCortexAuth:
class TestGetConn:
@mock.patch(f"{MODULE_PATH}.SnowflakeRestTokenProvider", autospec=True)
@mock.patch(f"{MODULE_PATH}.SnowflakeHook", autospec=True)
-
@mock.patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider",
autospec=True)
def test_get_conn_uses_explicit_credentials_despite_connection_password(
- self, mock_infer_provider, mock_hook_cls, mock_provider_cls
+ self, mock_hook_cls, mock_provider_cls
):
- """End-to-end: get_conn() must not raise and must not fall back to
env-var auth.
+ """End-to-end: get_conn() must not raise when the connection also
carries a password.
- ``infer_provider`` is only reached by the base hook's ``except
TypeError`` fallback
- (``pydantic_ai.py``'s ``_provider_factory``), so asserting it was
never called pins that
- the explicit-credentials path was taken -- ``model is not None`` alone
would also pass
- if this hook's kwargs were rejected and pydantic-ai silently fell back
to
- ``SNOWFLAKE_ACCOUNT``/``SNOWFLAKE_TOKEN`` env vars instead.
+ The base hook raises ``ValueError`` (it never falls back to env-var
auth) when the
+ provider class rejects the kwargs it derives from the connection, so a
model that
+ resolves means the explicit-credentials path was taken.
"""
mock_hook_cls.return_value._get_static_conn_params = {"account":
"airflow"}
@@ -276,7 +273,6 @@ class TestGetConn:
assert model is not None
mock_hook_cls.assert_called_once_with(snowflake_conn_id="snowflake_conn")
- mock_infer_provider.assert_not_called()
@mock.patch(f"{MODULE_PATH}.SnowflakeRestTokenProvider", autospec=True)
@mock.patch(f"{MODULE_PATH}.SnowflakeHook", autospec=True)