kaxil commented on code in PR #72156:
URL: https://github.com/apache/airflow/pull/72156#discussion_r4042148747
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,33 +178,107 @@ def _get_provider_kwargs(
kwargs["base_url"] = base_url
return kwargs
+ def _get_conn_and_extra(self) -> tuple[Connection, dict[str, Any]]:
+ """Return this hook's connection and its deserialized extra, fetching
at most once."""
+ if self._conn is None:
+ self._conn = self.get_connection(self.llm_conn_id)
+ self._conn_extra_dejson = self._conn.extra_dejson
+ return self._conn, self._conn_extra_dejson
+
def get_conn(self) -> Model:
"""
Return a configured pydantic-ai ``Model``.
- Resolution order:
+ Resolution order for this hook's own connection:
1. **Explicit credentials** — when :meth:`_get_provider_kwargs` returns
a non-empty dict the provider class is instantiated with those
kwargs
and wrapped in a ``provider_factory``.
2. **Default resolution** — delegates to pydantic-ai ``infer_model``
which reads standard env vars (``OPENAI_API_KEY``, ``AWS_PROFILE``,
…).
+ A bare ``model_id`` (one with no recognized platform prefix) is
qualified with
+ this connection's own platform before either of the above -- see the
class
+ docstring's ``model_id`` entry for the resolution and
fallback-forwarding rules.
+
+ When ``fallback_conn_ids`` is configured (on the hook or in the
+ connection's extra) the resolved models are wrapped in a pydantic-ai
+ ``FallbackModel``, so a provider outage moves to the next connection
+ *within the same task attempt* instead of failing the task.
+
+ Two costs of that wrapping are worth knowing before configuring a long
+ chain. A ``timeout`` in ``ModelSettings`` is applied by pydantic-ai to
+ every model in the chain rather than to the chain as a whole, so the
+ worst-case wait is the timeout multiplied by the number of connections.
+ And there is no circuit breaker: every call retries the primary first,
+ so during an outage each task instance pays the primary's timeout
again.
+ Keep the primary's timeout short to bound both.
+
The resolved model is cached for the lifetime of this hook instance.
"""
if self._model is not None:
return self._model
- conn = self.get_connection(self.llm_conn_id) if self._conn is None
else self._conn
- extra: dict[str, Any] = (
- conn.extra_dejson if self._conn_extra_dejson is None else
self._conn_extra_dejson
- )
+ model = self._resolve_own_model()
+ fallback_models = self._resolve_fallback_models()
+ self._model = FallbackModel(model, *fallback_models) if
fallback_models else model
+ return self._model
+
+ def _qualify_model_name(self, model_name: str) -> str:
+ """
+ Prefix a bare model name with this connection's platform.
+
+ A name is treated as already pinning a platform only when the segment
before
+ its first ``:`` is itself a provider pydantic-ai recognizes (e.g.
+ ``"openai:gpt-4"``) -- see :func:`_has_recognized_provider_prefix`.
Everything
+ else is a bare name, even one that happens to contain a ``:`` of its
own (e.g.
+ Bedrock's version-suffixed ``"us.anthropic.claude-opus-4-6-v1:0"``),
and is
+ prefixed with :attr:`model_provider`; the generic ``pydanticai``
connection type
+ has no platform of its own (``model_provider`` is ``None``), so a bare
name there
+ raises instead of reaching pydantic-ai's own, less actionable
``Unknown model``
+ error.
+ """
+ if _has_recognized_provider_prefix(model_name):
Review Comment:
`"test"` has no colon, so it falls through to the `model_provider` branch: a
generic `pydanticai` connection with `extra={"model": "test"}` now raises
`ValueError` where `infer_model` returned pydantic-ai's `TestModel` at both
2.23.0 and current, and on the vendor subclasses it silently becomes
`azure:test` / `bedrock:test` / `google-cloud:test`. A typo'd prefix lands in
the same branch, where the raise calls `openi:gpt-5` a "bare model name" and
tells the user to set a `provider:model` string they already set, while
pydantic-ai names the real problem at both ends of the supported range --
`Unknown provider: openi` at the 2.23.0 floor, `Unknown model: openi:gpt-5. Did
you mean 'openai:gpt-5'?` at 2.42. An early `if model_name == "test": return
model_name` restores the sentinel on all four classes, and naming the
unrecognized segment would cover the typo.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,33 +178,107 @@ def _get_provider_kwargs(
kwargs["base_url"] = base_url
return kwargs
+ def _get_conn_and_extra(self) -> tuple[Connection, dict[str, Any]]:
+ """Return this hook's connection and its deserialized extra, fetching
at most once."""
+ if self._conn is None:
+ self._conn = self.get_connection(self.llm_conn_id)
+ self._conn_extra_dejson = self._conn.extra_dejson
+ return self._conn, self._conn_extra_dejson
+
def get_conn(self) -> Model:
"""
Return a configured pydantic-ai ``Model``.
- Resolution order:
+ Resolution order for this hook's own connection:
1. **Explicit credentials** — when :meth:`_get_provider_kwargs` returns
a non-empty dict the provider class is instantiated with those
kwargs
and wrapped in a ``provider_factory``.
2. **Default resolution** — delegates to pydantic-ai ``infer_model``
which reads standard env vars (``OPENAI_API_KEY``, ``AWS_PROFILE``,
…).
+ A bare ``model_id`` (one with no recognized platform prefix) is
qualified with
+ this connection's own platform before either of the above -- see the
class
+ docstring's ``model_id`` entry for the resolution and
fallback-forwarding rules.
+
+ When ``fallback_conn_ids`` is configured (on the hook or in the
+ connection's extra) the resolved models are wrapped in a pydantic-ai
+ ``FallbackModel``, so a provider outage moves to the next connection
+ *within the same task attempt* instead of failing the task.
+
+ Two costs of that wrapping are worth knowing before configuring a long
+ chain. A ``timeout`` in ``ModelSettings`` is applied by pydantic-ai to
+ every model in the chain rather than to the chain as a whole, so the
+ worst-case wait is the timeout multiplied by the number of connections.
+ And there is no circuit breaker: every call retries the primary first,
+ so during an outage each task instance pays the primary's timeout
again.
+ Keep the primary's timeout short to bound both.
+
The resolved model is cached for the lifetime of this hook instance.
"""
if self._model is not None:
return self._model
- conn = self.get_connection(self.llm_conn_id) if self._conn is None
else self._conn
- extra: dict[str, Any] = (
- conn.extra_dejson if self._conn_extra_dejson is None else
self._conn_extra_dejson
- )
+ model = self._resolve_own_model()
+ fallback_models = self._resolve_fallback_models()
+ self._model = FallbackModel(model, *fallback_models) if
fallback_models else model
Review Comment:
`fallback_on` is left to pydantic-ai's default, which is `(ModelAPIError,)`
at both 2.23.0 and current, so `UnexpectedModelBehavior`, `UsageLimitExceeded`
and `ContentFilterError` propagate without trying the next connection. That
scope is the thing the new docs pin in three places (the retry-layers table,
the changelog note, and retry_policies.rst), and pyproject says only
`pydantic-ai-slim>=2.23.0` with no upper bound, so an upstream default change
would move the documented behaviour silently. Passing
`fallback_on=(ModelAPIError,)` explicitly here keeps the contract in this repo,
and the kwarg exists at the 2.23.0 floor.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,33 +178,107 @@ def _get_provider_kwargs(
kwargs["base_url"] = base_url
return kwargs
+ def _get_conn_and_extra(self) -> tuple[Connection, dict[str, Any]]:
+ """Return this hook's connection and its deserialized extra, fetching
at most once."""
+ if self._conn is None:
+ self._conn = self.get_connection(self.llm_conn_id)
+ self._conn_extra_dejson = self._conn.extra_dejson
+ return self._conn, self._conn_extra_dejson
+
def get_conn(self) -> Model:
"""
Return a configured pydantic-ai ``Model``.
- Resolution order:
+ Resolution order for this hook's own connection:
1. **Explicit credentials** — when :meth:`_get_provider_kwargs` returns
a non-empty dict the provider class is instantiated with those
kwargs
and wrapped in a ``provider_factory``.
2. **Default resolution** — delegates to pydantic-ai ``infer_model``
which reads standard env vars (``OPENAI_API_KEY``, ``AWS_PROFILE``,
…).
+ A bare ``model_id`` (one with no recognized platform prefix) is
qualified with
+ this connection's own platform before either of the above -- see the
class
+ docstring's ``model_id`` entry for the resolution and
fallback-forwarding rules.
+
+ When ``fallback_conn_ids`` is configured (on the hook or in the
+ connection's extra) the resolved models are wrapped in a pydantic-ai
+ ``FallbackModel``, so a provider outage moves to the next connection
+ *within the same task attempt* instead of failing the task.
+
+ Two costs of that wrapping are worth knowing before configuring a long
+ chain. A ``timeout`` in ``ModelSettings`` is applied by pydantic-ai to
+ every model in the chain rather than to the chain as a whole, so the
+ worst-case wait is the timeout multiplied by the number of connections.
+ And there is no circuit breaker: every call retries the primary first,
+ so during an outage each task instance pays the primary's timeout
again.
+ Keep the primary's timeout short to bound both.
+
The resolved model is cached for the lifetime of this hook instance.
"""
if self._model is not None:
return self._model
- conn = self.get_connection(self.llm_conn_id) if self._conn is None
else self._conn
- extra: dict[str, Any] = (
- conn.extra_dejson if self._conn_extra_dejson is None else
self._conn_extra_dejson
- )
+ model = self._resolve_own_model()
+ fallback_models = self._resolve_fallback_models()
+ self._model = FallbackModel(model, *fallback_models) if
fallback_models else model
+ return self._model
+
+ def _qualify_model_name(self, model_name: str) -> str:
+ """
+ Prefix a bare model name with this connection's platform.
+
+ A name is treated as already pinning a platform only when the segment
before
+ its first ``:`` is itself a provider pydantic-ai recognizes (e.g.
+ ``"openai:gpt-4"``) -- see :func:`_has_recognized_provider_prefix`.
Everything
+ else is a bare name, even one that happens to contain a ``:`` of its
own (e.g.
+ Bedrock's version-suffixed ``"us.anthropic.claude-opus-4-6-v1:0"``),
and is
+ prefixed with :attr:`model_provider`; the generic ``pydanticai``
connection type
+ has no platform of its own (``model_provider`` is ``None``), so a bare
name there
+ raises instead of reaching pydantic-ai's own, less actionable
``Unknown model``
+ error.
+ """
+ if _has_recognized_provider_prefix(model_name):
+ return model_name
+ if self.model_provider is None:
+ raise ValueError(
+ f"Connection '{self.llm_conn_id}' has no default model
provider, so the bare "
Review Comment:
Beyond the typo'd-prefix case, this message can name a model the user never
put on the connection it blames: a Bedrock primary with a bare name plus a
generic `pydanticai` fallback with no `model` of its own produces "Connection
'openai_dr' has no default model provider, so the bare model name
'claude-opus-4-5' cannot be resolved", and nothing on `openai_dr` mentions that
name. It is also wrong for `policies/retry.py:209`, which builds the base
`PydanticAIHook` directly rather than through `get_hook`, so a
`pydanticai_bedrock` connection reached through `LLMRetryPolicy` is told its
connection type has no default provider when that type does. Saying the name
was forwarded from the primary would cover the first; the second looks like a
retry.py follow-up rather than a change here.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,33 +178,107 @@ def _get_provider_kwargs(
kwargs["base_url"] = base_url
return kwargs
+ def _get_conn_and_extra(self) -> tuple[Connection, dict[str, Any]]:
+ """Return this hook's connection and its deserialized extra, fetching
at most once."""
+ if self._conn is None:
+ self._conn = self.get_connection(self.llm_conn_id)
+ self._conn_extra_dejson = self._conn.extra_dejson
+ return self._conn, self._conn_extra_dejson
+
def get_conn(self) -> Model:
"""
Return a configured pydantic-ai ``Model``.
- Resolution order:
+ Resolution order for this hook's own connection:
1. **Explicit credentials** — when :meth:`_get_provider_kwargs` returns
a non-empty dict the provider class is instantiated with those
kwargs
and wrapped in a ``provider_factory``.
2. **Default resolution** — delegates to pydantic-ai ``infer_model``
which reads standard env vars (``OPENAI_API_KEY``, ``AWS_PROFILE``,
…).
+ A bare ``model_id`` (one with no recognized platform prefix) is
qualified with
+ this connection's own platform before either of the above -- see the
class
+ docstring's ``model_id`` entry for the resolution and
fallback-forwarding rules.
+
+ When ``fallback_conn_ids`` is configured (on the hook or in the
+ connection's extra) the resolved models are wrapped in a pydantic-ai
+ ``FallbackModel``, so a provider outage moves to the next connection
+ *within the same task attempt* instead of failing the task.
+
+ Two costs of that wrapping are worth knowing before configuring a long
+ chain. A ``timeout`` in ``ModelSettings`` is applied by pydantic-ai to
+ every model in the chain rather than to the chain as a whole, so the
+ worst-case wait is the timeout multiplied by the number of connections.
+ And there is no circuit breaker: every call retries the primary first,
+ so during an outage each task instance pays the primary's timeout
again.
+ Keep the primary's timeout short to bound both.
+
The resolved model is cached for the lifetime of this hook instance.
"""
if self._model is not None:
return self._model
- conn = self.get_connection(self.llm_conn_id) if self._conn is None
else self._conn
- extra: dict[str, Any] = (
- conn.extra_dejson if self._conn_extra_dejson is None else
self._conn_extra_dejson
- )
+ model = self._resolve_own_model()
+ fallback_models = self._resolve_fallback_models()
+ self._model = FallbackModel(model, *fallback_models) if
fallback_models else model
+ return self._model
+
+ def _qualify_model_name(self, model_name: str) -> str:
+ """
+ Prefix a bare model name with this connection's platform.
+
+ A name is treated as already pinning a platform only when the segment
before
+ its first ``:`` is itself a provider pydantic-ai recognizes (e.g.
+ ``"openai:gpt-4"``) -- see :func:`_has_recognized_provider_prefix`.
Everything
+ else is a bare name, even one that happens to contain a ``:`` of its
own (e.g.
+ Bedrock's version-suffixed ``"us.anthropic.claude-opus-4-6-v1:0"``),
and is
+ prefixed with :attr:`model_provider`; the generic ``pydanticai``
connection type
+ has no platform of its own (``model_provider`` is ``None``), so a bare
name there
+ raises instead of reaching pydantic-ai's own, less actionable
``Unknown model``
+ error.
+ """
+ if _has_recognized_provider_prefix(model_name):
+ return model_name
+ if self.model_provider is None:
+ raise ValueError(
+ f"Connection '{self.llm_conn_id}' has no default model
provider, so the bare "
+ f"model name '{model_name}' cannot be resolved. Use a vendor
connection type "
+ "(Azure/Bedrock/Vertex) or set an explicit 'provider:model'
string."
+ )
+ return f"{self.model_provider}:{model_name}"
Review Comment:
On a vendor connection this double-prefixes any name whose colon-prefix is
not a recognized provider, so `google-vertex:gemini-2.0-flash` on a
`pydanticai_vertex` connection becomes
`google-cloud:google-vertex:gemini-2.0-flash` and resolves cleanly into a
`GoogleModel` named `google-vertex:gemini-2.0-flash`, failing only once the
request reaches Vertex. That spelling is not hypothetical -- this provider
shipped `google-vertex:` and `google-gla:` in its own tests and docs until
#72011, so existing connections carry it, and `test_connection` goes from
reporting `Unknown provider: google-vertex` to "Model resolved successfully."
Keeping the Bedrock-native-id behaviour but logging a warning whenever a
prefixed name is treated as a bare vendor id would leave a stale prefix
discoverable.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -178,24 +303,101 @@ def _provider_factory(pname: str) -> Any:
)
return infer_provider(pname)
- self._model = infer_model(model_name,
provider_factory=_provider_factory)
- return self._model
+ return infer_model(model_name, provider_factory=_provider_factory)
- self._model = infer_model(model_name)
- return self._model
+ return infer_model(model_name)
- def _get_conn_if_model_configured(self) -> Model | None:
- """Return the hook model only when the hook or connection explicitly
configures one."""
- if self.model_id:
- return self.get_conn()
+ def _get_fallback_conn_ids(self) -> list[str]:
+ """Return the configured fallback connection IDs, hook argument
winning over the extra."""
+ if self.fallback_conn_ids is not None:
+ raw: Any = self.fallback_conn_ids
+ else:
+ _, extra = self._get_conn_and_extra()
+ raw = extra.get(FALLBACK_CONN_IDS_EXTRA_KEY)
+ if raw is None:
+ raw = []
+
+ if not isinstance(raw, (list, tuple)) or not all(isinstance(item, str)
and item for item in raw):
+ raise ValueError(
+ f"{FALLBACK_CONN_IDS_EXTRA_KEY} for connection
'{self.llm_conn_id}' must be a list "
+ f"of non-empty connection IDs, got {raw!r}."
+ )
+ return list(raw)
- conn = self.get_connection(self.llm_conn_id)
- self._conn = conn
- self._conn_extra_dejson = conn.extra_dejson
+ def _resolve_fallback_models(self) -> list[Model]:
+ """
+ Resolve one ``Model`` per fallback connection, in the configured order.
Review Comment:
This 20-line docstring restates the forwarding rules that the class
docstring's `model_id` entry and `_resolve_own_model`'s `:param
forwarded_model_id:` already state, and the Bedrock embedded-colon example
appears four times in this one file. Could the rule live in one place, with the
others pointing at it? The behaviour reads fine; it is the volume of duplicated
prose a reviewer has to diff against itself.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -178,24 +303,101 @@ def _provider_factory(pname: str) -> Any:
)
return infer_provider(pname)
- self._model = infer_model(model_name,
provider_factory=_provider_factory)
- return self._model
+ return infer_model(model_name, provider_factory=_provider_factory)
- self._model = infer_model(model_name)
- return self._model
+ return infer_model(model_name)
- def _get_conn_if_model_configured(self) -> Model | None:
- """Return the hook model only when the hook or connection explicitly
configures one."""
- if self.model_id:
- return self.get_conn()
+ def _get_fallback_conn_ids(self) -> list[str]:
+ """Return the configured fallback connection IDs, hook argument
winning over the extra."""
+ if self.fallback_conn_ids is not None:
+ raw: Any = self.fallback_conn_ids
+ else:
+ _, extra = self._get_conn_and_extra()
+ raw = extra.get(FALLBACK_CONN_IDS_EXTRA_KEY)
+ if raw is None:
+ raw = []
+
+ if not isinstance(raw, (list, tuple)) or not all(isinstance(item, str)
and item for item in raw):
Review Comment:
The new Fallback Connections conn-field renders as a textarea
(`FieldStringArray`, since the schema is `type: [array, null]` with
`items.type: string`) and splits on newline, and its blur handler only nulls
the all-empty case. So a trailing newline saves `["anthropic_prod", ""]` and
this check then rejects the whole chain at task runtime rather than at save
time. Dropping blank entries, or stripping them, would make the field behave
the way the textarea invites people to use it.
##########
providers/common/ai/docs/connections/pydantic_ai_bedrock.rst:
##########
@@ -107,6 +115,12 @@ more than one credential source is set at once, the bearer
token
- The environment-variable / instance-role credential chain
(``AWS_PROFILE``, IAM role, …) when none of the fields above are set.
+Fallback Connections
Review Comment:
This entry landed inside the Credentials section, after its bullet list,
rather than up in Configuring the Connection with the other conn-fields. The
generic and Azure pages put the same entry in Configuring the Connection, and a
fallback chain is not a credential. Same thing on the Vertex page at line 136.
##########
providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py:
##########
@@ -240,6 +246,585 @@ def test_get_conn_caches_model(self, mock_infer_model):
mock_infer_model.assert_called_once()
+class _ConnRegistry:
+ """
+ In-memory stand-in for connection and hook lookup.
+
+ ``_resolve_fallback_models`` goes through ``BaseHook.get_hook``, which
needs both the
+ metadata DB and provider discovery; this resolves both from a dict instead.
+ """
+
+ def __init__(self) -> None:
+ self.conns: dict[str, Connection] = {}
+ self.hook_classes: dict[str, type[PydanticAIHook]] = {}
+
+ def add(
+ self,
+ conn_id: str,
+ *,
+ conn_type: str = "pydanticai",
+ hook_class: type[PydanticAIHook] = PydanticAIHook,
+ password: str | None = None,
+ extra: dict | None = None,
+ ) -> None:
+ self.conns[conn_id] = Connection(
+ conn_id=conn_id,
+ conn_type=conn_type,
+ password=password,
+ extra=json.dumps(extra) if extra else None,
+ )
+ self.hook_classes[conn_id] = hook_class
+
+ def get_connection(self, conn_id: str) -> Connection:
+ try:
+ return self.conns[conn_id]
+ except KeyError:
+ raise AirflowNotFoundException(f"The conn_id `{conn_id}` isn't
defined") from None
+
+ def get_hook(self, conn_id: str, hook_params: dict | None = None):
+ if conn_id not in self.conns:
+ raise AirflowNotFoundException(f"The conn_id `{conn_id}` isn't
defined")
+ hook_class = self.hook_classes[conn_id]
+ return hook_class(llm_conn_id=conn_id, **(hook_params or {}))
+
+
[email protected]
+def registry():
+ """Patch connection and hook lookup onto a registry the test populates."""
+ reg = _ConnRegistry()
+ with (
+ patch.object(PydanticAIHook, "get_connection",
side_effect=reg.get_connection),
Review Comment:
The "fetched at most once" promise on `_get_conn_and_extra` has no test, and
a fallback chain multiplies what a regression here would cost -- one Execution
API round trip per hop. The fixture mock already records its calls, so pinning
the count is a couple of lines.
##########
providers/common/ai/docs/provider_fallback.rst:
##########
@@ -0,0 +1,208 @@
+ .. 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.
+
+Provider fallback
+=================
+
+A single ``llm_conn_id`` gives a task one provider. When that provider is
down, the task
+fails and retries into the same outage. ``fallback_conn_ids`` gives the
connection an
+ordered list of other connections to try, so a provider outage moves to the
next vendor
+inside the same task attempt.
+
+Configure it on the connection
+------------------------------
+
+Put the chain in the primary connection's extra:
+
+.. code-block:: json
+
+ {
+ "model": "openai:gpt-5",
+ "fallback_conn_ids": ["anthropic_prod", "bedrock_dr"]
+ }
+
+Every entry is an Airflow connection ID, resolved through the hook registered
for its own
+connection type. A chain can therefore mix vendors whose credentials live in
different
+connection fields — ``pydanticai`` for OpenAI, ``pydanticai_bedrock`` for a
Bedrock
+standby — without the Dag knowing anything about either.
+
+That is the point of configuring it here rather than in Dag code: the Dag
keeps naming one
+connection, and whoever administers the connections owns the failover
topology. Changing a
+standby provider is a connection edit, not a Dag deployment.
+
+A *bare* model name (e.g. ``"gpt-5"`` rather than ``"openai:gpt-5"``) is
forwarded down
+the chain as a logical model name: each connection that has no ``model`` of
its own
+resolves that name against its own platform, so one bare name can reach a
primary and
+every fallback without repeating it per connection. It does not matter where
the primary's
+name comes from -- the ``Model`` field on its connection and a ``model_id`` on
the operator
+or hook are forwarded alike. A fallback with its own ``model`` in
+extra always uses that instead -- this is how a fallback pins a spelling the
forwarded
+name would not produce, such as Bedrock's region-prefixed ``us.anthropic.``
model ids. A
+name that already pins a platform (its segment before the first ``:`` is
itself a
+recognized provider, e.g. ``"openai:gpt-5"``) is *not* forwarded; a fallback
with no
+``model`` of its own still raises "no model specified" rather than trying a
prefixed name
+meant for a different provider. See :doc:`connections/pydantic_ai_azure`,
+:doc:`connections/pydantic_ai_bedrock` and
:doc:`connections/pydantic_ai_vertex` for how
+each vendor connection resolves a bare name.
+
+Configure it on the operator
+-----------------------------
+
+``fallback_conn_ids`` is also a parameter on
+:class:`~airflow.providers.common.ai.operators.llm.LLMOperator`,
+:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`, their
subclasses,
+and the matching ``@task.llm`` / ``@task.agent`` decorators -- mirroring
``model_id``,
+which is settable at the same two layers:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_operator_argument]
+ :end-before: [END howto_llm_fallback_operator_argument]
+
+The operator argument overrides the connection's extra field, and passing
``[]``
+explicitly disables a chain configured there -- ``None`` (the default) reads
whatever
+the connection says. Use this when a task should own its own failover order
instead of
+inheriting it from however the connection is configured.
+
+Configure it in code
+--------------------
+
+:class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook` also
takes the list
+directly, which is what a task that constructs the hook itself (rather than
through an
+operator) should use:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_hook_argument]
+ :end-before: [END howto_llm_fallback_hook_argument]
+
+The argument wins over the connection's extra, and passing ``[]`` explicitly
disables a
+chain configured there. Omitting it entirely (``None``) means "use whatever
the connection
+says", which is why the two are not interchangeable.
+
+Where this sits among the retry layers
+--------------------------------------
+
+Three mechanisms handle failure at different time scales, and they compose
rather than
+replace each other:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 40 35
+
+ * - Scope
+ - Mechanism
+ - Handles
+ * - Within one model call
+ - ``fallback_conn_ids``
+ - This vendor's API is returning errors; ask the next one (any
``ModelAPIError``,
+ transient or not)
+ * - Within one task attempt
+ - ``timeout`` in pydantic-ai's ``ModelSettings``
+ - This vendor is slow rather than down
+ * - Across task attempts
+ - :doc:`retry_policies` (including ``LLMRetryPolicy``)
+ - Whether this failure is worth retrying at all
+
+A chain does not remove the need for the outer layers. It covers the case
where another
+vendor can answer the same prompt now; a bad prompt, an exhausted quota on
every vendor, or
+a permanent data error still has to be decided by the retry policy.
+
+Adding a chain changes what the retry layer sees. When every connection in the
chain
+fails, the exception the task raises is
``pydantic_ai.exceptions.FallbackExceptionGroup``,
+not the last provider's own exception, so retry rules matched against a
provider-specific
+exception type stop matching. Before adding a chain to a connection that Dags
already use,
+read :doc:`retry_policies` -- the section "When the connection also carries a
fallback
+chain" spells out what to check.
+
+Costs to know before configuring a long chain
+---------------------------------------------
+
+**The timeout multiplies.** pydantic-ai applies a ``ModelSettings`` timeout to
each model
+in the chain, not to the chain as a whole. A 30-second timeout across three
connections is
+a 90-second worst case for one call.
+
+**There is no circuit breaker.** Every call tries the primary first. During an
outage each
+task instance pays the primary's timeout again before failing over, so 500
mapped tasks pay
+it 500 times. Keeping the primary's timeout short bounds both of these.
+
+**Chains are not resolved recursively.** If a connection listed as a fallback
declares its
+own ``fallback_conn_ids``, resolution fails with an error rather than
following it. List
+every provider directly on the primary; a flat chain is the one you can read
off a single
+connection.
+
+**Non-transient errors still walk the whole chain.** Failover triggers on
pydantic-ai's
+``ModelAPIError`` family, which includes ``ModelHTTPError`` -- raised for any
4xx as well as
+5xx. A malformed prompt, an expired key, or a misspelled model name is
therefore retried
Review Comment:
Of these three, only the malformed prompt is shared by every connection. An
expired key is per-connection, so the fallback presents its own and normally
answers, which is the whole point of the chain; and a misspelled model name is
only forwarded when it is bare, since a platform-pinned name is not forwarded
and each fallback uses its own model. The claim that a 4xx walks the chain
holds, but the "N billable calls for a request that was never going to succeed"
cost only follows for an error every connection shares.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -178,24 +303,101 @@ def _provider_factory(pname: str) -> Any:
)
return infer_provider(pname)
- self._model = infer_model(model_name,
provider_factory=_provider_factory)
- return self._model
+ return infer_model(model_name, provider_factory=_provider_factory)
- self._model = infer_model(model_name)
- return self._model
+ return infer_model(model_name)
- def _get_conn_if_model_configured(self) -> Model | None:
- """Return the hook model only when the hook or connection explicitly
configures one."""
- if self.model_id:
- return self.get_conn()
+ def _get_fallback_conn_ids(self) -> list[str]:
+ """Return the configured fallback connection IDs, hook argument
winning over the extra."""
+ if self.fallback_conn_ids is not None:
+ raw: Any = self.fallback_conn_ids
+ else:
+ _, extra = self._get_conn_and_extra()
+ raw = extra.get(FALLBACK_CONN_IDS_EXTRA_KEY)
+ if raw is None:
+ raw = []
+
+ if not isinstance(raw, (list, tuple)) or not all(isinstance(item, str)
and item for item in raw):
+ raise ValueError(
+ f"{FALLBACK_CONN_IDS_EXTRA_KEY} for connection
'{self.llm_conn_id}' must be a list "
+ f"of non-empty connection IDs, got {raw!r}."
+ )
+ return list(raw)
- conn = self.get_connection(self.llm_conn_id)
- self._conn = conn
- self._conn_extra_dejson = conn.extra_dejson
+ def _resolve_fallback_models(self) -> list[Model]:
+ """
+ Resolve one ``Model`` per fallback connection, in the configured order.
+
+ Each connection is resolved through the hook registered for its own
+ ``conn_type``, so a chain can mix providers whose credentials live in
+ different connection fields. The primary's configured model name --
its
+ ``model_id`` argument, or the ``model`` in its own ``extra`` -- is
forwarded
+ to each fallback as a logical model name: a fallback connection with
its own
+ ``model`` in ``extra`` uses that instead, but a fallback with none
falls
+ back to the forwarded name, qualified with *its own* platform prefix.
+ Only a *bare* forwarded name is usable this way -- a forwarded name
that
+ already pins a platform (e.g. ``"openai:gpt-5"``) names a model of the
+ primary's provider, not this fallback's, so it is not applied; that
+ fallback still raises "no model specified" unless its own ``extra``
sets
+ a ``model``. Whether a name already pins a platform is decided by
+ :func:`_has_recognized_provider_prefix`, not by whether it merely
contains a
+ ``:`` -- some vendors' native model ids contain one of their own (e.g.
Bedrock's
+ version-suffixed ``us.anthropic.claude-opus-4-6-v1:0``).
+ """
+ fallback_conn_ids = self._get_fallback_conn_ids()
+ if not fallback_conn_ids:
+ return []
+
+ forwarded_model_id = self._get_configured_model_name()
+
+ models: list[Model] = []
+ seen: set[str] = set()
+ for conn_id in fallback_conn_ids:
+ if conn_id == self.llm_conn_id:
+ raise ValueError(
+ f"Fallback chain for connection '{self.llm_conn_id}' lists
the primary "
+ "connection as one of its own fallbacks; every fallback
must differ from "
+ "the primary."
+ )
+ if conn_id in seen:
+ raise ValueError(
+ f"Fallback chain for connection '{self.llm_conn_id}' lists
'{conn_id}' more "
+ "than once; every entry must be distinct."
+ )
+ seen.add(conn_id)
+
+ # ``BaseHook.get_hook`` dispatches on the connection's
``conn_type`` and does not
+ # constrain the result to this class, so the type has to be
checked here.
+ hook = PydanticAIHook.get_hook(conn_id)
+ if not isinstance(hook, PydanticAIHook):
+ raise ValueError(
+ f"Fallback connection '{conn_id}' resolves to
{type(hook).__name__}, which is "
+ "not a PydanticAIHook. Only pydanticai connection types
can be used as "
+ f"fallbacks for '{self.llm_conn_id}'."
+ )
+ if hook._get_fallback_conn_ids():
+ raise ValueError(
+ f"Fallback connection '{conn_id}' declares its own "
+ f"{FALLBACK_CONN_IDS_EXTRA_KEY}. Chains are not resolved
recursively -- list "
+ f"every provider directly on '{self.llm_conn_id}' instead."
+ )
+
models.append(hook._resolve_own_model(forwarded_model_id=forwarded_model_id))
+
+ self.log.info("Resolved LLM fallback chain: %s", " ->
".join([self.llm_conn_id, *fallback_conn_ids]))
Review Comment:
This fires only after the whole chain resolves, so it is absent in exactly
the case where it would earn its keep. A typo'd fallback id raises
``AirflowNotFoundException("The conn_id `llm_fallback` isn't defined")``, and
on the connection-driven path the docs recommend, the DAG never mentions
`llm_fallback` at all, so the task log gives no hint where the name came from.
Moving the line above the loop would make every mid-resolution failure
attributable.
##########
providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py:
##########
@@ -240,6 +246,585 @@ def test_get_conn_caches_model(self, mock_infer_model):
mock_infer_model.assert_called_once()
+class _ConnRegistry:
+ """
+ In-memory stand-in for connection and hook lookup.
+
+ ``_resolve_fallback_models`` goes through ``BaseHook.get_hook``, which
needs both the
+ metadata DB and provider discovery; this resolves both from a dict instead.
+ """
+
+ def __init__(self) -> None:
+ self.conns: dict[str, Connection] = {}
+ self.hook_classes: dict[str, type[PydanticAIHook]] = {}
+
+ def add(
+ self,
+ conn_id: str,
+ *,
+ conn_type: str = "pydanticai",
+ hook_class: type[PydanticAIHook] = PydanticAIHook,
+ password: str | None = None,
+ extra: dict | None = None,
+ ) -> None:
+ self.conns[conn_id] = Connection(
+ conn_id=conn_id,
+ conn_type=conn_type,
+ password=password,
+ extra=json.dumps(extra) if extra else None,
+ )
+ self.hook_classes[conn_id] = hook_class
+
+ def get_connection(self, conn_id: str) -> Connection:
+ try:
+ return self.conns[conn_id]
+ except KeyError:
+ raise AirflowNotFoundException(f"The conn_id `{conn_id}` isn't
defined") from None
+
+ def get_hook(self, conn_id: str, hook_params: dict | None = None):
+ if conn_id not in self.conns:
+ raise AirflowNotFoundException(f"The conn_id `{conn_id}` isn't
defined")
+ hook_class = self.hook_classes[conn_id]
+ return hook_class(llm_conn_id=conn_id, **(hook_params or {}))
+
+
[email protected]
+def registry():
+ """Patch connection and hook lookup onto a registry the test populates."""
+ reg = _ConnRegistry()
+ with (
+ patch.object(PydanticAIHook, "get_connection",
side_effect=reg.get_connection),
+ patch.object(PydanticAIHook, "get_hook", side_effect=reg.get_hook),
+ ):
+ yield reg
+
+
+class _InferModelStub:
+ """Resolve every model string to its own recognisable model, and record
how it was built."""
+
+ def __init__(self, mock: MagicMock) -> None:
+ self.mock = mock
+ self.models: dict[str, MagicMock] = {}
+
+ def __call__(self, model_name: str, **kwargs) -> MagicMock:
+ return self.models.setdefault(model_name, MagicMock(spec=Model,
name=model_name))
+
+ def provider_kwargs_for(self, model_name: str, infer_provider_class:
MagicMock) -> dict:
+ """Return the kwargs the provider for *model_name* would be
constructed with."""
+ factory = next(
+ call.kwargs["provider_factory"] for call in
self.mock.call_args_list if call.args[0] == model_name
+ )
+ infer_provider_class.return_value.reset_mock()
+ factory(model_name.split(":")[0])
+ return infer_provider_class.return_value.call_args.kwargs
+
+
[email protected]
+def infer_model_stub():
+ """Patch ``infer_model`` so tests can tell the models of a chain apart."""
+ with patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model",
autospec=True) as mock:
+ stub = _InferModelStub(mock)
+ mock.side_effect = stub
+ yield stub
+
+
+class TestPydanticAIHookModelProviderResolution:
+ """Bare model names get qualified with a connection's own platform
prefix."""
+
+ @pytest.mark.parametrize(
+ ("hook_class", "conn_type", "prefix"),
+ [
+ pytest.param(PydanticAIAzureHook, "pydanticai_azure", "azure",
id="azure"),
+ pytest.param(PydanticAIBedrockHook, "pydanticai_bedrock",
"bedrock", id="bedrock"),
+ pytest.param(PydanticAIVertexHook, "pydanticai_vertex",
"google-cloud", id="vertex"),
+ ],
+ )
+ def test_bare_model_id_gets_platform_prefix(
+ self, registry, infer_model_stub, hook_class, conn_type, prefix
+ ):
+ registry.add("primary", conn_type=conn_type, hook_class=hook_class,
extra={"model": "foo"})
+ hook = hook_class(llm_conn_id="primary")
+
+ assert hook.get_conn() is infer_model_stub.models[f"{prefix}:foo"]
+
+ def test_prefixed_model_id_used_verbatim(self, registry, infer_model_stub):
+ """A name that already contains ``:`` pins its own platform and is
never re-prefixed."""
+ registry.add(
+ "primary",
+ conn_type="pydanticai_azure",
+ hook_class=PydanticAIAzureHook,
+ extra={"model": "openai:gpt-4"},
+ )
+ hook = PydanticAIAzureHook(llm_conn_id="primary")
+
+ assert hook.get_conn() is infer_model_stub.models["openai:gpt-4"]
+
+ def test_generic_connection_bare_name_raises_actionable_error(self,
registry, infer_model_stub):
+ """The generic ``pydanticai`` connection type has no platform of its
own."""
+ registry.add("primary", extra={"model": "gpt-4"})
+ hook = PydanticAIHook(llm_conn_id="primary")
+
+ with pytest.raises(ValueError, match="primary") as exc_info:
+ hook.get_conn()
+
+ assert "gpt-4" in str(exc_info.value)
+
+ def test_vertex_bare_model_id_ignores_credential_shape(self, registry,
infer_model_stub):
+ """Vertex's default platform never depends on which credential fields
are set.
+
+ ``api_key`` in this hook's extra can mean either the Generative
Language API or
+ Vertex API-key auth, so it cannot decide the platform -- there is
deliberately no
+ inference here, only the class-level default.
+ """
+ registry.add(
+ "primary",
+ conn_type="pydanticai_vertex",
+ hook_class=PydanticAIVertexHook,
+ extra={"model": "gemini-2.0-flash", "api_key": "some-key"},
+ )
+ hook = PydanticAIVertexHook(llm_conn_id="primary")
+
+ assert hook.get_conn() is
infer_model_stub.models["google-cloud:gemini-2.0-flash"]
+
+ def
test_bedrock_bare_model_id_with_embedded_colon_gets_platform_prefix(self,
registry, infer_model_stub):
+ """A ``:`` alone doesn't pin a platform -- Bedrock's own ids contain
one.
+
+ Bedrock's version-suffixed ids (e.g.
``us.anthropic.claude-opus-4-6-v1:0``) contain
+ a ``:`` that is not a pydantic-ai provider name, so a bare copy of one
must still get
+ the ``bedrock:`` prefix, not be treated as already-qualified.
+
+ Mutation canary: reverting
``_qualify_model_name``/``_has_recognized_provider_prefix``
+ to the old ``":" in model_name`` check makes this resolve to the
unprefixed
+ ``"us.anthropic.claude-opus-4-6-v1:0"`` instead, failing the ``is``
identity assertion
+ (a different key in ``infer_model_stub.models``).
+ """
+ registry.add(
+ "primary",
+ conn_type="pydanticai_bedrock",
+ hook_class=PydanticAIBedrockHook,
+ extra={"model": "us.anthropic.claude-opus-4-6-v1:0"},
+ )
+ hook = PydanticAIBedrockHook(llm_conn_id="primary")
+
+ assert hook.get_conn() is
infer_model_stub.models["bedrock:us.anthropic.claude-opus-4-6-v1:0"]
+
+ def test_prefixed_model_id_with_embedded_colon_used_verbatim(self,
registry, infer_model_stub):
+ """A name already pinning a recognized platform is never re-prefixed,
even with an
+ embedded ``:`` of its own.
+
+ Mutation canary: dropping the ``infer_provider_class`` recognition
check (treating
+ every ``:`` split the same) has no effect on *this* test by itself
since the string
+ already starts with a recognized prefix -- what would catch a
regression here is a
+ mutation that re-adds prefixing unconditionally (e.g. always prepending
+ ``model_provider`` regardless of ``_has_recognized_provider_prefix``'s
result), which
+ would turn the resolved key into
+ ``"bedrock:bedrock:us.anthropic.claude-opus-4-6-v1:0"`` and fail the
identity assertion.
+ """
+ registry.add(
+ "primary",
+ conn_type="pydanticai_bedrock",
+ hook_class=PydanticAIBedrockHook,
+ extra={"model": "bedrock:us.anthropic.claude-opus-4-6-v1:0"},
+ )
+ hook = PydanticAIBedrockHook(llm_conn_id="primary")
+
+ assert hook.get_conn() is
infer_model_stub.models["bedrock:us.anthropic.claude-opus-4-6-v1:0"]
+
+ def
test_generic_connection_unrecognized_prefix_raises_actionable_error(self,
registry, infer_model_stub):
+ """A ``:`` whose left segment isn't a real provider must not slip past
as "prefixed".
+
+ On the generic connection type (no platform of its own) a name like
+ ``"us.anthropic.claude-opus-4-6-v1:0"`` must raise this hook's own
actionable error
+ naming the connection, not be forwarded to pydantic-ai's
``infer_model`` where it
+ would instead raise the less actionable ``UserError: Unknown model``.
+
+ Mutation canary: reverting to the old ``":" in model_name`` check
makes this string
+ look "already prefixed" (since it contains a ``:``) and skips the
``ValueError`` raise
+ entirely -- the ``pytest.raises(ValueError, match="primary")`` block
would then fail
+ because no exception is raised (the stubbed ``infer_model`` would
resolve it instead).
+ """
+ registry.add("primary", extra={"model":
"us.anthropic.claude-opus-4-6-v1:0"})
+ hook = PydanticAIHook(llm_conn_id="primary")
+
+ with pytest.raises(ValueError, match="primary") as exc_info:
+ hook.get_conn()
+
+ assert "us.anthropic.claude-opus-4-6-v1:0" in str(exc_info.value)
+
+
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class",
autospec=True)
+ def test_import_error_from_recognized_provider_counts_as_prefixed(self,
mock_infer_provider_class):
+ """A recognized provider name whose optional dependency isn't
installed still counts
+ as a platform prefix -- ``infer_provider_class`` raises
``ImportError`` (not
+ ``ValueError``) for a name it recognizes but can't import.
+
+ Mutation canary: changing the ``except ImportError`` branch to
``return False``
+ makes this assert ``True`` fail.
+ """
+ mock_infer_provider_class.side_effect = ImportError("Please install
the 'azure' extra")
+
+ assert _has_recognized_provider_prefix("azure:foo") is True
+
+
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class",
autospec=True)
+ def test_value_error_from_unknown_provider_counts_as_bare(self,
mock_infer_provider_class):
+ """An unrecognized name raises ``ValueError`` and is treated as a bare
model name --
+ the counterpart to the ``ImportError`` case above, proving the two
exceptions are
+ told apart rather than both mapping to the same answer.
+
+ Mutation canary: changing the ``except ValueError`` branch to ``return
True``
+ makes this assert ``False`` fail.
+ """
+ mock_infer_provider_class.side_effect = ValueError("Unknown provider:
bogus")
+
+ assert _has_recognized_provider_prefix("bogus:foo") is False
+
+
+class TestPydanticAIHookFallback:
+ def test_no_fallback_returns_the_bare_model(self, registry,
infer_model_stub):
+ """Without a chain the resolved model is not wrapped at all."""
+ registry.add("primary", extra={"model": "openai:gpt-5.6-sol"})
+ hook = PydanticAIHook(llm_conn_id="primary")
+
+ assert hook.get_conn() is infer_model_stub.models["openai:gpt-5.6-sol"]
+
+ def test_param_builds_chain_in_order(self, registry, infer_model_stub):
+ registry.add("primary", extra={"model": "openai:gpt-5.6-sol"})
+ registry.add("second", extra={"model": "anthropic:claude-opus-4-6"})
+ registry.add("third", extra={"model": "groq:llama-4"})
+
+ hook = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=["second", "third"])
+ model = hook.get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models == [
+ infer_model_stub.models["openai:gpt-5.6-sol"],
+ infer_model_stub.models["anthropic:claude-opus-4-6"],
+ infer_model_stub.models["groq:llama-4"],
+ ]
+
+ def test_chain_from_connection_extra(self, registry, infer_model_stub):
+ """A deployment manager can configure failover without touching Dag
code."""
+ registry.add(
+ "primary",
+ extra={"model": "openai:gpt-5.6-sol", "fallback_conn_ids":
["second"]},
+ )
+ registry.add("second", extra={"model": "anthropic:claude-opus-4-6"})
+
+ model = PydanticAIHook(llm_conn_id="primary").get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models == [
+ infer_model_stub.models["openai:gpt-5.6-sol"],
+ infer_model_stub.models["anthropic:claude-opus-4-6"],
+ ]
+
+ def test_param_overrides_extra(self, registry, infer_model_stub):
+ registry.add(
+ "primary",
+ extra={"model": "openai:gpt-5.6-sol", "fallback_conn_ids":
["ignored"]},
+ )
+ registry.add("ignored", extra={"model": "groq:llama-4"})
+ registry.add("second", extra={"model": "anthropic:claude-opus-4-6"})
+
+ model = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=["second"]).get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models[1] is
infer_model_stub.models["anthropic:claude-opus-4-6"]
+
+ def test_empty_list_param_disables_the_extra_chain(self, registry,
infer_model_stub):
+ """``[]`` is an explicit opt-out, distinct from ``None`` meaning "read
the extra"."""
+ registry.add(
+ "primary",
+ extra={"model": "openai:gpt-5.6-sol", "fallback_conn_ids":
["second"]},
+ )
+ registry.add("second", extra={"model": "anthropic:claude-opus-4-6"})
+
+ model = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=[]).get_conn()
+
+ assert model is infer_model_stub.models["openai:gpt-5.6-sol"]
+
+ def test_chain_can_span_providers(self, registry, infer_model_stub):
+ """Each connection resolves through its own hook class, so credentials
differ per hop."""
+ registry.add("primary", password="sk-openai", extra={"model":
"openai:gpt-5.6-sol"})
+ registry.add(
+ "bedrock_dr",
+ conn_type="pydanticai_bedrock",
+ hook_class=PydanticAIBedrockHook,
+ extra={
+ "model": "bedrock:us.anthropic.claude-opus-4-5",
+ "region_name": "us-east-1",
+ "aws_access_key_id": "AKIA-test",
+ "aws_secret_access_key": "secret",
+ },
+ )
+
+ with patch(
+
"airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class",
autospec=True
+ ) as mock_infer_provider_class:
+ mock_infer_provider_class.return_value =
MagicMock(return_value=MagicMock())
+ hook = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=["bedrock_dr"])
+ model = hook.get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models == [
+ infer_model_stub.models["openai:gpt-5.6-sol"],
+
infer_model_stub.models["bedrock:us.anthropic.claude-opus-4-5"],
+ ]
+
+ # Each hop is built by its own hook's field mapping: the primary
from
+ # password/host, the Bedrock hop from its extra.
+ assert infer_model_stub.provider_kwargs_for("openai:gpt-5.6-sol",
mock_infer_provider_class) == {
+ "api_key": "sk-openai"
+ }
+ assert infer_model_stub.provider_kwargs_for(
+ "bedrock:us.anthropic.claude-opus-4-5",
mock_infer_provider_class
+ ) == {
+ "region_name": "us-east-1",
+ "aws_access_key_id": "AKIA-test",
+ "aws_secret_access_key": "secret",
+ }
+
+ def test_bare_model_id_forwarded_to_fallback_without_own_model(self,
registry, infer_model_stub):
+ """A bare ``model_id`` flows to a fallback with none, qualified with
*that* fallback's platform."""
+ registry.add("primary", conn_type="pydanticai_azure",
hook_class=PydanticAIAzureHook)
+ registry.add("bedrock_dr", conn_type="pydanticai_bedrock",
hook_class=PydanticAIBedrockHook)
+
+ hook = PydanticAIAzureHook(
+ llm_conn_id="primary", model_id="gpt-5-nano",
fallback_conn_ids=["bedrock_dr"]
+ )
+ model = hook.get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models == [
+ infer_model_stub.models["azure:gpt-5-nano"],
+ infer_model_stub.models["bedrock:gpt-5-nano"],
+ ]
+
+ def
test_bare_connection_model_forwarded_to_fallback_without_own_model(self,
registry, infer_model_stub):
+ """A bare model from the primary's own ``extra`` forwards like a
``model_id`` argument.
+
+ This is the connection-driven shape the docs lead with: neither the
model nor the chain
+ is named in Dag code, so forwarding the constructor argument alone
never fires.
+
+ Mutation canary: forwarding ``self.model_id`` rather than the
primary's configured name
+ makes ``get_conn()`` raise "No model specified for connection
'bedrock_dr'" here, because
+ ``model_id`` is ``None`` in this shape -- failing before either
assertion is reached.
+ """
+ registry.add(
+ "primary",
+ conn_type="pydanticai_azure",
+ hook_class=PydanticAIAzureHook,
+ extra={"model": "gpt-5-nano", "fallback_conn_ids": ["bedrock_dr"]},
+ )
+ registry.add("bedrock_dr", conn_type="pydanticai_bedrock",
hook_class=PydanticAIBedrockHook)
+
+ model = PydanticAIAzureHook(llm_conn_id="primary").get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models == [
+ infer_model_stub.models["azure:gpt-5-nano"],
+ infer_model_stub.models["bedrock:gpt-5-nano"],
+ ]
+
+ def test_fallback_own_model_overrides_forwarded(self, registry,
infer_model_stub):
+ """A fallback's own ``model`` extra wins over anything forwarded from
the primary."""
+ registry.add("primary", conn_type="pydanticai_azure",
hook_class=PydanticAIAzureHook)
+ registry.add(
+ "bedrock_dr",
+ conn_type="pydanticai_bedrock",
+ hook_class=PydanticAIBedrockHook,
+ extra={"model": "bedrock:us.anthropic.claude-opus-4-5"},
+ )
+
+ hook = PydanticAIAzureHook(
+ llm_conn_id="primary", model_id="gpt-5-nano",
fallback_conn_ids=["bedrock_dr"]
+ )
+ model = hook.get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models[1] is
infer_model_stub.models["bedrock:us.anthropic.claude-opus-4-5"]
+
+ def test_prefixed_model_id_not_forwarded_to_fallback(self, registry,
infer_model_stub):
+ """A prefixed ``model_id`` pins the primary's own platform and is
unusable on a fallback."""
+ registry.add("primary", extra={"model": "openai:gpt-5.6-sol"})
+ registry.add("second") # no model of its own
+
+ hook = PydanticAIHook(llm_conn_id="primary", model_id="openai:gpt-5",
fallback_conn_ids=["second"])
+ with pytest.raises(ValueError, match="No model specified for
connection 'second'"):
+ hook.get_conn()
+
+ def
test_bedrock_style_bare_model_id_forwarded_to_fallback_without_own_model(
+ self, registry, infer_model_stub
+ ):
+ """A primary's bare model id with an embedded ``:`` of its own is
still forwardable.
+
+ The forwarding-priority check in ``_resolve_own_model`` has to use the
same
+ "recognized provider prefix" test as ``_qualify_model_name`` -- not
the old
+ ``":" in forwarded_model_id`` check -- or a Bedrock-style bare id
(which contains a
+ ``:`` from its own version suffix, not a platform prefix) would be
wrongly treated as
+ already-pinned and never forwarded. Using a different fallback
platform (Azure) makes
+ the resolved keys distinguishable so a wrong-prefix regression cannot
hide behind two
+ identical strings.
+
+ Mutation canary: reverting the forwarding check to ``":" not in
forwarded_model_id``
+ makes the fallback treat ``"us.anthropic.claude-opus-4-6-v1:0"`` as
already-pinned and
+ skip it, so ``hook.get_conn()`` itself raises "No model specified for
connection
+ 'azure_dr'" instead of returning -- failing this test before the
identity assertion is
+ even reached.
+ """
+ registry.add("primary", conn_type="pydanticai_bedrock",
hook_class=PydanticAIBedrockHook)
+ registry.add("azure_dr", conn_type="pydanticai_azure",
hook_class=PydanticAIAzureHook)
+
+ hook = PydanticAIBedrockHook(
+ llm_conn_id="primary",
+ model_id="us.anthropic.claude-opus-4-6-v1:0",
+ fallback_conn_ids=["azure_dr"],
+ )
+ model = hook.get_conn()
+
+ assert isinstance(model, FallbackModel)
+ assert model.models == [
+
infer_model_stub.models["bedrock:us.anthropic.claude-opus-4-6-v1:0"],
+ infer_model_stub.models["azure:us.anthropic.claude-opus-4-6-v1:0"],
+ ]
+
+ def test_non_pydanticai_fallback_raises(self, registry, infer_model_stub):
+ """``BaseHook.get_hook`` dispatches on conn_type alone and can return
anything."""
+ registry.add("primary", extra={"model": "openai:gpt-5.6-sol"})
+ registry.add("wrong_type", conn_type="langchain")
+ registry.hook_classes["wrong_type"] = MagicMock # type:
ignore[assignment]
+
+ hook = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=["wrong_type"])
+ with pytest.raises(ValueError, match="not a PydanticAIHook"):
+ hook.get_conn()
+
+ def test_nested_chain_raises(self, registry, infer_model_stub):
+ registry.add("primary", extra={"model": "openai:gpt-5.6-sol"})
+ registry.add(
+ "second",
+ extra={"model": "anthropic:claude-opus-4-6", "fallback_conn_ids":
["third"]},
+ )
+ registry.add("third", extra={"model": "groq:llama-4"})
+
+ hook = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=["second"])
+ with pytest.raises(ValueError, match="second.*not resolved
recursively"):
+ hook.get_conn()
+
+ @pytest.mark.parametrize(
+ ("fallback_conn_ids", "match"),
+ [
+ pytest.param(["second", "second"], "more than once",
id="repeated-fallback"),
+ pytest.param(["primary"], "as one of its own fallbacks",
id="primary-repeated"),
+ ],
+ )
+ def test_duplicate_conn_id_raises(self, registry, infer_model_stub,
fallback_conn_ids, match):
+ registry.add("primary", extra={"model": "openai:gpt-5.6-sol"})
+ registry.add("second", extra={"model": "anthropic:claude-opus-4-6"})
+
+ hook = PydanticAIHook(llm_conn_id="primary",
fallback_conn_ids=fallback_conn_ids)
+ with pytest.raises(ValueError, match=match):
+ hook.get_conn()
+
+ @pytest.mark.parametrize(
+ "fallback_conn_ids",
+ [
+ pytest.param("second,third", id="comma-separated-string"),
+ pytest.param(["second", ""], id="empty-entry"),
Review Comment:
This parametrize case pins the all-or-nothing rejection as correct, and
`_get_fallback_conn_ids` applies the same `isinstance(item, str) and item`
check whether the list arrived as a hook kwarg or out of connection extra. So
the case a user actually hits -- the conn-field textarea leaving a trailing
blank, which saves `["anthropic_prod", ""]` -- is certified here as an opaque
`ValueError` rather than a working one-entry chain. Whichever way the
blank-entry question is settled, this line has to move with it, so it is worth
deciding now which behaviour is intended.
##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py:
##########
@@ -65,6 +65,9 @@ class LLMSQLQueryOperator(LLMOperator):
:param llm_conn_id: Connection ID for the LLM provider.
:param model_id: Model identifier (e.g. ``"openai:gpt-4o"``).
Overrides the model stored in the connection's extra field.
+ :param fallback_conn_ids: Connection IDs to fail over to, in order, when
Review Comment:
This `:param:` stops after "Overrides the ``fallback_conn_ids`` set in the
connection's extra field", dropping the sentence `LLMOperator` and
`AgentOperator` both carry: `None` reads the connection's own extra, an
explicit `[]` disables a chain configured there. The behaviour is identical
here, since the kwarg reaches the same hook, so a reader of only this
operator's rendered docs cannot learn that `[]` is the off switch. Same
truncation on `llm_branch.py:48`, `llm_file_analysis.py:50` and
`llm_schema_compare.py:100`.
##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,33 +178,107 @@ def _get_provider_kwargs(
kwargs["base_url"] = base_url
return kwargs
+ def _get_conn_and_extra(self) -> tuple[Connection, dict[str, Any]]:
+ """Return this hook's connection and its deserialized extra, fetching
at most once."""
+ if self._conn is None:
+ self._conn = self.get_connection(self.llm_conn_id)
+ self._conn_extra_dejson = self._conn.extra_dejson
+ return self._conn, self._conn_extra_dejson
+
def get_conn(self) -> Model:
"""
Return a configured pydantic-ai ``Model``.
- Resolution order:
+ Resolution order for this hook's own connection:
1. **Explicit credentials** — when :meth:`_get_provider_kwargs` returns
a non-empty dict the provider class is instantiated with those
kwargs
and wrapped in a ``provider_factory``.
2. **Default resolution** — delegates to pydantic-ai ``infer_model``
which reads standard env vars (``OPENAI_API_KEY``, ``AWS_PROFILE``,
…).
+ A bare ``model_id`` (one with no recognized platform prefix) is
qualified with
+ this connection's own platform before either of the above -- see the
class
+ docstring's ``model_id`` entry for the resolution and
fallback-forwarding rules.
+
+ When ``fallback_conn_ids`` is configured (on the hook or in the
+ connection's extra) the resolved models are wrapped in a pydantic-ai
+ ``FallbackModel``, so a provider outage moves to the next connection
+ *within the same task attempt* instead of failing the task.
+
+ Two costs of that wrapping are worth knowing before configuring a long
+ chain. A ``timeout`` in ``ModelSettings`` is applied by pydantic-ai to
+ every model in the chain rather than to the chain as a whole, so the
+ worst-case wait is the timeout multiplied by the number of connections.
+ And there is no circuit breaker: every call retries the primary first,
+ so during an outage each task instance pays the primary's timeout
again.
+ Keep the primary's timeout short to bound both.
+
The resolved model is cached for the lifetime of this hook instance.
"""
if self._model is not None:
return self._model
- conn = self.get_connection(self.llm_conn_id) if self._conn is None
else self._conn
- extra: dict[str, Any] = (
- conn.extra_dejson if self._conn_extra_dejson is None else
self._conn_extra_dejson
- )
+ model = self._resolve_own_model()
+ fallback_models = self._resolve_fallback_models()
+ self._model = FallbackModel(model, *fallback_models) if
fallback_models else model
+ return self._model
+
+ def _qualify_model_name(self, model_name: str) -> str:
+ """
+ Prefix a bare model name with this connection's platform.
+
+ A name is treated as already pinning a platform only when the segment
before
+ its first ``:`` is itself a provider pydantic-ai recognizes (e.g.
+ ``"openai:gpt-4"``) -- see :func:`_has_recognized_provider_prefix`.
Everything
+ else is a bare name, even one that happens to contain a ``:`` of its
own (e.g.
+ Bedrock's version-suffixed ``"us.anthropic.claude-opus-4-6-v1:0"``),
and is
+ prefixed with :attr:`model_provider`; the generic ``pydanticai``
connection type
+ has no platform of its own (``model_provider`` is ``None``), so a bare
name there
+ raises instead of reaching pydantic-ai's own, less actionable
``Unknown model``
+ error.
+ """
+ if _has_recognized_provider_prefix(model_name):
+ return model_name
+ if self.model_provider is None:
+ raise ValueError(
+ f"Connection '{self.llm_conn_id}' has no default model
provider, so the bare "
+ f"model name '{model_name}' cannot be resolved. Use a vendor
connection type "
+ "(Azure/Bedrock/Vertex) or set an explicit 'provider:model'
string."
+ )
+ return f"{self.model_provider}:{model_name}"
+
+ def _get_configured_model_name(self) -> str | KnownModelName | None:
+ """Return the model name this connection configures, hook argument
winning over the extra."""
+ if self.model_id:
+ return self.model_id
+ _, extra = self._get_conn_and_extra()
+ return extra.get("model")
+
+ def _resolve_own_model(self, *, forwarded_model_id: str | None = None) ->
Model:
+ """
+ Resolve the ``Model`` for this hook's own connection, ignoring any
fallback chain.
+
+ :param forwarded_model_id: The primary connection's configured model
name,
+ forwarded down a fallback chain by
:meth:`_resolve_fallback_models`.
+ Used only when this
+ connection configures no ``model_id``/``model`` of its own, and
only when it
+ is a bare name: a name that already pins a platform (see
+ :func:`_has_recognized_provider_prefix`) names a model of the
*primary's*
+ provider, not this connection's, so it is not forwarded -- this
connection
+ still raises "no model specified" in that case.
+ """
+ conn, extra = self._get_conn_and_extra()
- model_name: str | KnownModelName = self.model_id or extra.get("model",
"")
+ model_name: str | KnownModelName | None =
self._get_configured_model_name()
+ if not model_name and forwarded_model_id and not
_has_recognized_provider_prefix(forwarded_model_id):
Review Comment:
A colon-bearing name is classified as bare precisely because it is a native
vendor id, and a native vendor id is never valid on another platform, yet it is
still forwarded across platforms. A Bedrock primary with
`us.anthropic.claude-opus-4-5-v1:0` hands an Azure fallback that has no `model`
of its own `azure:us.anthropic.claude-opus-4-5-v1:0`, which resolves without
complaint and then 404s during the outage the chain existed to cover, while
`test_connection` reports the chain healthy. Gating the forward on the two
hooks sharing a `model_provider` when the name contains a colon would refuse
only the combinations that provably cannot work, leaving Bedrock-to-Bedrock and
the colon-free cross-vendor case untouched.
##########
providers/common/ai/docs/provider_fallback.rst:
##########
@@ -0,0 +1,208 @@
+ .. 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.
+
+Provider fallback
+=================
+
+A single ``llm_conn_id`` gives a task one provider. When that provider is
down, the task
+fails and retries into the same outage. ``fallback_conn_ids`` gives the
connection an
+ordered list of other connections to try, so a provider outage moves to the
next vendor
+inside the same task attempt.
+
+Configure it on the connection
+------------------------------
+
+Put the chain in the primary connection's extra:
+
+.. code-block:: json
+
+ {
+ "model": "openai:gpt-5",
+ "fallback_conn_ids": ["anthropic_prod", "bedrock_dr"]
+ }
+
+Every entry is an Airflow connection ID, resolved through the hook registered
for its own
+connection type. A chain can therefore mix vendors whose credentials live in
different
+connection fields — ``pydanticai`` for OpenAI, ``pydanticai_bedrock`` for a
Bedrock
+standby — without the Dag knowing anything about either.
+
+That is the point of configuring it here rather than in Dag code: the Dag
keeps naming one
+connection, and whoever administers the connections owns the failover
topology. Changing a
+standby provider is a connection edit, not a Dag deployment.
+
+A *bare* model name (e.g. ``"gpt-5"`` rather than ``"openai:gpt-5"``) is
forwarded down
+the chain as a logical model name: each connection that has no ``model`` of
its own
+resolves that name against its own platform, so one bare name can reach a
primary and
+every fallback without repeating it per connection. It does not matter where
the primary's
+name comes from -- the ``Model`` field on its connection and a ``model_id`` on
the operator
+or hook are forwarded alike. A fallback with its own ``model`` in
+extra always uses that instead -- this is how a fallback pins a spelling the
forwarded
+name would not produce, such as Bedrock's region-prefixed ``us.anthropic.``
model ids. A
+name that already pins a platform (its segment before the first ``:`` is
itself a
+recognized provider, e.g. ``"openai:gpt-5"``) is *not* forwarded; a fallback
with no
+``model`` of its own still raises "no model specified" rather than trying a
prefixed name
+meant for a different provider. See :doc:`connections/pydantic_ai_azure`,
+:doc:`connections/pydantic_ai_bedrock` and
:doc:`connections/pydantic_ai_vertex` for how
+each vendor connection resolves a bare name.
+
+Configure it on the operator
+-----------------------------
+
+``fallback_conn_ids`` is also a parameter on
+:class:`~airflow.providers.common.ai.operators.llm.LLMOperator`,
+:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`, their
subclasses,
+and the matching ``@task.llm`` / ``@task.agent`` decorators -- mirroring
``model_id``,
+which is settable at the same two layers:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_operator_argument]
+ :end-before: [END howto_llm_fallback_operator_argument]
+
+The operator argument overrides the connection's extra field, and passing
``[]``
+explicitly disables a chain configured there -- ``None`` (the default) reads
whatever
+the connection says. Use this when a task should own its own failover order
instead of
+inheriting it from however the connection is configured.
+
+Configure it in code
+--------------------
+
+:class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook` also
takes the list
+directly, which is what a task that constructs the hook itself (rather than
through an
+operator) should use:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
Review Comment:
The dag this pulls in passes `fallback_conn_ids=["llm_fallback"]` to a hook
whose connection `llm_primary_down` already carries that same chain in its
extra, so nothing in the snippet shows the argument winning over the extra that
the paragraph below then describes. The operator example above gets this right
by pointing at `llm_primary_down_no_chain`; the same switch here would make the
snippet carry its claim.
##########
providers/common/ai/docs/provider_fallback.rst:
##########
@@ -0,0 +1,208 @@
+ .. 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.
+
+Provider fallback
+=================
+
+A single ``llm_conn_id`` gives a task one provider. When that provider is
down, the task
+fails and retries into the same outage. ``fallback_conn_ids`` gives the
connection an
+ordered list of other connections to try, so a provider outage moves to the
next vendor
+inside the same task attempt.
+
+Configure it on the connection
+------------------------------
+
+Put the chain in the primary connection's extra:
+
+.. code-block:: json
+
+ {
+ "model": "openai:gpt-5",
+ "fallback_conn_ids": ["anthropic_prod", "bedrock_dr"]
+ }
+
+Every entry is an Airflow connection ID, resolved through the hook registered
for its own
+connection type. A chain can therefore mix vendors whose credentials live in
different
+connection fields — ``pydanticai`` for OpenAI, ``pydanticai_bedrock`` for a
Bedrock
+standby — without the Dag knowing anything about either.
+
+That is the point of configuring it here rather than in Dag code: the Dag
keeps naming one
+connection, and whoever administers the connections owns the failover
topology. Changing a
+standby provider is a connection edit, not a Dag deployment.
+
+A *bare* model name (e.g. ``"gpt-5"`` rather than ``"openai:gpt-5"``) is
forwarded down
+the chain as a logical model name: each connection that has no ``model`` of
its own
+resolves that name against its own platform, so one bare name can reach a
primary and
+every fallback without repeating it per connection. It does not matter where
the primary's
+name comes from -- the ``Model`` field on its connection and a ``model_id`` on
the operator
+or hook are forwarded alike. A fallback with its own ``model`` in
+extra always uses that instead -- this is how a fallback pins a spelling the
forwarded
+name would not produce, such as Bedrock's region-prefixed ``us.anthropic.``
model ids. A
+name that already pins a platform (its segment before the first ``:`` is
itself a
+recognized provider, e.g. ``"openai:gpt-5"``) is *not* forwarded; a fallback
with no
+``model`` of its own still raises "no model specified" rather than trying a
prefixed name
+meant for a different provider. See :doc:`connections/pydantic_ai_azure`,
+:doc:`connections/pydantic_ai_bedrock` and
:doc:`connections/pydantic_ai_vertex` for how
+each vendor connection resolves a bare name.
+
+Configure it on the operator
+-----------------------------
+
+``fallback_conn_ids`` is also a parameter on
+:class:`~airflow.providers.common.ai.operators.llm.LLMOperator`,
+:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`, their
subclasses,
+and the matching ``@task.llm`` / ``@task.agent`` decorators -- mirroring
``model_id``,
+which is settable at the same two layers:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_operator_argument]
+ :end-before: [END howto_llm_fallback_operator_argument]
+
+The operator argument overrides the connection's extra field, and passing
``[]``
+explicitly disables a chain configured there -- ``None`` (the default) reads
whatever
+the connection says. Use this when a task should own its own failover order
instead of
+inheriting it from however the connection is configured.
+
+Configure it in code
+--------------------
+
+:class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook` also
takes the list
+directly, which is what a task that constructs the hook itself (rather than
through an
+operator) should use:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_hook_argument]
+ :end-before: [END howto_llm_fallback_hook_argument]
+
+The argument wins over the connection's extra, and passing ``[]`` explicitly
disables a
+chain configured there. Omitting it entirely (``None``) means "use whatever
the connection
+says", which is why the two are not interchangeable.
+
+Where this sits among the retry layers
+--------------------------------------
+
+Three mechanisms handle failure at different time scales, and they compose
rather than
+replace each other:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 40 35
+
+ * - Scope
+ - Mechanism
+ - Handles
+ * - Within one model call
+ - ``fallback_conn_ids``
+ - This vendor's API is returning errors; ask the next one (any
``ModelAPIError``,
+ transient or not)
+ * - Within one task attempt
+ - ``timeout`` in pydantic-ai's ``ModelSettings``
+ - This vendor is slow rather than down
+ * - Across task attempts
+ - :doc:`retry_policies` (including ``LLMRetryPolicy``)
+ - Whether this failure is worth retrying at all
+
+A chain does not remove the need for the outer layers. It covers the case
where another
+vendor can answer the same prompt now; a bad prompt, an exhausted quota on
every vendor, or
+a permanent data error still has to be decided by the retry policy.
+
+Adding a chain changes what the retry layer sees. When every connection in the
chain
+fails, the exception the task raises is
``pydantic_ai.exceptions.FallbackExceptionGroup``,
+not the last provider's own exception, so retry rules matched against a
provider-specific
+exception type stop matching. Before adding a chain to a connection that Dags
already use,
+read :doc:`retry_policies` -- the section "When the connection also carries a
fallback
+chain" spells out what to check.
+
+Costs to know before configuring a long chain
+---------------------------------------------
+
+**The timeout multiplies.** pydantic-ai applies a ``ModelSettings`` timeout to
each model
+in the chain, not to the chain as a whole. A 30-second timeout across three
connections is
+a 90-second worst case for one call.
+
+**There is no circuit breaker.** Every call tries the primary first. During an
outage each
+task instance pays the primary's timeout again before failing over, so 500
mapped tasks pay
+it 500 times. Keeping the primary's timeout short bounds both of these.
+
+**Chains are not resolved recursively.** If a connection listed as a fallback
declares its
+own ``fallback_conn_ids``, resolution fails with an error rather than
following it. List
+every provider directly on the primary; a flat chain is the one you can read
off a single
+connection.
+
+**Non-transient errors still walk the whole chain.** Failover triggers on
pydantic-ai's
+``ModelAPIError`` family, which includes ``ModelHTTPError`` -- raised for any
4xx as well as
+5xx. A malformed prompt, an expired key, or a misspelled model name is
therefore retried
+against every connection in the chain before the task sees the failure: N
requests, N
+timeouts, and N billable calls for a request that was never going to succeed.
Keep chains
+short, and put deterministic rules for those errors in :doc:`retry_policies`.
+
+**Airflow's task-level** ``retries`` **multiplies on top of the chain.** A
task with
+``retries=5`` gets up to six attempts -- the initial attempt plus five retries
-- before
+Airflow marks it failed, and each attempt walks the whole chain again if every
connection
+is still down. Against the three-connection chain in the example above (the
primary plus
Review Comment:
Both exampleinclude blocks above use a one-primary-plus-one-fallback chain,
so a reader who checks "the example above" will conclude the arithmetic is
wrong. The only three-connection chain on the page is the JSON snippet near the
top, so naming that one explicitly would fix it. The 6 x 3 = 18 itself is right.
##########
providers/common/ai/docs/provider_fallback.rst:
##########
@@ -0,0 +1,208 @@
+ .. 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.
+
+Provider fallback
+=================
+
+A single ``llm_conn_id`` gives a task one provider. When that provider is
down, the task
+fails and retries into the same outage. ``fallback_conn_ids`` gives the
connection an
+ordered list of other connections to try, so a provider outage moves to the
next vendor
+inside the same task attempt.
+
+Configure it on the connection
+------------------------------
+
+Put the chain in the primary connection's extra:
+
+.. code-block:: json
+
+ {
+ "model": "openai:gpt-5",
+ "fallback_conn_ids": ["anthropic_prod", "bedrock_dr"]
+ }
+
+Every entry is an Airflow connection ID, resolved through the hook registered
for its own
+connection type. A chain can therefore mix vendors whose credentials live in
different
+connection fields — ``pydanticai`` for OpenAI, ``pydanticai_bedrock`` for a
Bedrock
+standby — without the Dag knowing anything about either.
+
+That is the point of configuring it here rather than in Dag code: the Dag
keeps naming one
+connection, and whoever administers the connections owns the failover
topology. Changing a
+standby provider is a connection edit, not a Dag deployment.
+
+A *bare* model name (e.g. ``"gpt-5"`` rather than ``"openai:gpt-5"``) is
forwarded down
+the chain as a logical model name: each connection that has no ``model`` of
its own
+resolves that name against its own platform, so one bare name can reach a
primary and
+every fallback without repeating it per connection. It does not matter where
the primary's
+name comes from -- the ``Model`` field on its connection and a ``model_id`` on
the operator
+or hook are forwarded alike. A fallback with its own ``model`` in
+extra always uses that instead -- this is how a fallback pins a spelling the
forwarded
+name would not produce, such as Bedrock's region-prefixed ``us.anthropic.``
model ids. A
+name that already pins a platform (its segment before the first ``:`` is
itself a
+recognized provider, e.g. ``"openai:gpt-5"``) is *not* forwarded; a fallback
with no
+``model`` of its own still raises "no model specified" rather than trying a
prefixed name
+meant for a different provider. See :doc:`connections/pydantic_ai_azure`,
+:doc:`connections/pydantic_ai_bedrock` and
:doc:`connections/pydantic_ai_vertex` for how
+each vendor connection resolves a bare name.
+
+Configure it on the operator
+-----------------------------
+
+``fallback_conn_ids`` is also a parameter on
+:class:`~airflow.providers.common.ai.operators.llm.LLMOperator`,
+:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`, their
subclasses,
+and the matching ``@task.llm`` / ``@task.agent`` decorators -- mirroring
``model_id``,
+which is settable at the same two layers:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_operator_argument]
+ :end-before: [END howto_llm_fallback_operator_argument]
+
+The operator argument overrides the connection's extra field, and passing
``[]``
+explicitly disables a chain configured there -- ``None`` (the default) reads
whatever
+the connection says. Use this when a task should own its own failover order
instead of
+inheriting it from however the connection is configured.
+
+Configure it in code
+--------------------
+
+:class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook` also
takes the list
+directly, which is what a task that constructs the hook itself (rather than
through an
+operator) should use:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_fallback.py
+ :language: python
+ :dedent: 0
+ :start-after: [START howto_llm_fallback_hook_argument]
+ :end-before: [END howto_llm_fallback_hook_argument]
+
+The argument wins over the connection's extra, and passing ``[]`` explicitly
disables a
+chain configured there. Omitting it entirely (``None``) means "use whatever
the connection
+says", which is why the two are not interchangeable.
+
+Where this sits among the retry layers
+--------------------------------------
+
+Three mechanisms handle failure at different time scales, and they compose
rather than
+replace each other:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 40 35
+
+ * - Scope
+ - Mechanism
+ - Handles
+ * - Within one model call
+ - ``fallback_conn_ids``
+ - This vendor's API is returning errors; ask the next one (any
``ModelAPIError``,
+ transient or not)
+ * - Within one task attempt
+ - ``timeout`` in pydantic-ai's ``ModelSettings``
+ - This vendor is slow rather than down
+ * - Across task attempts
+ - :doc:`retry_policies` (including ``LLMRetryPolicy``)
+ - Whether this failure is worth retrying at all
+
+A chain does not remove the need for the outer layers. It covers the case
where another
+vendor can answer the same prompt now; a bad prompt, an exhausted quota on
every vendor, or
+a permanent data error still has to be decided by the retry policy.
+
+Adding a chain changes what the retry layer sees. When every connection in the
chain
+fails, the exception the task raises is
``pydantic_ai.exceptions.FallbackExceptionGroup``,
+not the last provider's own exception, so retry rules matched against a
provider-specific
+exception type stop matching. Before adding a chain to a connection that Dags
already use,
+read :doc:`retry_policies` -- the section "When the connection also carries a
fallback
+chain" spells out what to check.
+
+Costs to know before configuring a long chain
+---------------------------------------------
+
+**The timeout multiplies.** pydantic-ai applies a ``ModelSettings`` timeout to
each model
+in the chain, not to the chain as a whole. A 30-second timeout across three
connections is
+a 90-second worst case for one call.
+
+**There is no circuit breaker.** Every call tries the primary first. During an
outage each
+task instance pays the primary's timeout again before failing over, so 500
mapped tasks pay
+it 500 times. Keeping the primary's timeout short bounds both of these.
+
+**Chains are not resolved recursively.** If a connection listed as a fallback
declares its
+own ``fallback_conn_ids``, resolution fails with an error rather than
following it. List
+every provider directly on the primary; a flat chain is the one you can read
off a single
+connection.
+
+**Non-transient errors still walk the whole chain.** Failover triggers on
pydantic-ai's
+``ModelAPIError`` family, which includes ``ModelHTTPError`` -- raised for any
4xx as well as
+5xx. A malformed prompt, an expired key, or a misspelled model name is
therefore retried
+against every connection in the chain before the task sees the failure: N
requests, N
+timeouts, and N billable calls for a request that was never going to succeed.
Keep chains
+short, and put deterministic rules for those errors in :doc:`retry_policies`.
+
+**Airflow's task-level** ``retries`` **multiplies on top of the chain.** A
task with
+``retries=5`` gets up to six attempts -- the initial attempt plus five retries
-- before
+Airflow marks it failed, and each attempt walks the whole chain again if every
connection
+is still down. Against the three-connection chain in the example above (the
primary plus
+two fallbacks), that is up to 18 upstream calls, not 3, before the task is
finally marked
+failed.
+
+**A bad fallback connection fails the whole chain, including a healthy
primary.** The
+primary and every fallback are resolved eagerly, before any of them is called,
so a
+misspelled fallback ``conn_id`` or a fallback connection missing its ``model``
raises
+immediately -- the task never reaches the primary, even though the primary
itself would
+have answered fine. Run ``test_connection`` on the primary to catch this
before it costs a
+task; see *Verifying a chain* below.
+
+Verifying a chain
+-----------------
+
+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 not reported this way -- the hook catches that and retries
with the
+env-var-based provider constructor, logging a warning either way; that retry
still raises a
+``pydantic_ai.exceptions.UserError`` if the required env var is also missing,
so check the
+logs for that failure mode rather than relying on ``test_connection``. It also
does not call the provider, so a
Review Comment:
`test_connection` does surface that `UserError`: it wraps `get_conn()` in
`except Exception` and returns `(False, str(e))`, and `UserError` subclasses
`RuntimeError`. The case it genuinely cannot show is the opposite one, where
the `TypeError` is swallowed because the env-var retry succeeded, so the
connection tests green while the credentials you set were ignored. Worth
pointing the reader at the logs for that case instead.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]