Lee-W commented on code in PR #72156:
URL: https://github.com/apache/airflow/pull/72156#discussion_r4045890749


##########
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:
   Settled towards dropping them, and the case is rewritten rather than 
removed: `["anthropic_prod", ""]` now asserts a working one-entry chain.
   



-- 
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]

Reply via email to