kaxil commented on code in PR #71437:
URL: https://github.com/apache/airflow/pull/71437#discussion_r3979085074


##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -535,3 +597,14 @@ def _get_provider_kwargs(
             )
 
         return kwargs
+
+
+_PROVIDER_KWARGS_MAPPER_BY_MODEL_PREFIX: dict[
+    str | None, Callable[[str | None, str | None, dict[str, Any]], dict[str, 
Any]]
+] = {
+    "azure": PydanticAIAzureHook._get_provider_kwargs,
+    "azure-responses": PydanticAIAzureHook._get_provider_kwargs,
+    "bedrock": PydanticAIBedrockHook._get_provider_kwargs,
+    "google": PydanticAIVertexHook._get_provider_kwargs,

Review Comment:
   `google` and `google-cloud` share the Vertex mapping, but 
`infer_provider_class` resolves them to different classes: 
`GoogleCloudProvider` accepts `project`/`location`/`credentials`, while 
`GoogleProvider` accepts only 
`api_key`/`base_url`/`client`/`http_client`/`retry_options`. So a 
`pydanticai-vertex` connection carrying the `project` and `location` its own 
conn-fields document, plus a `google:` model, raises `TypeError: 
GoogleProvider.__init__() got an unexpected keyword argument 'project'` on 
pydantic-ai 2.31.1 -- and with the `except TypeError` gone there is nothing 
left to catch it. This is not embeddings-only: `get_conn()` with `model: 
"google:gemini-2.0-flash"` returned a `GoogleModel` at d195f1e and raises the 
same `TypeError` now, and `test_get_provider_kwargs_api_key_gla_mode` already 
exercises the Vertex hook with a `google:` model. `google-cloud:` on the same 
connection is fine, so `google` looks like it needs its own entry emitting only 
`GoogleProvider`'s surface rat
 her than sharing the Vertex one.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,6 +138,35 @@ def _get_provider_kwargs(
             kwargs["base_url"] = base_url
         return kwargs
 
+    def _get_cached_connection(self, conn_id: str) -> Connection:
+        if conn_id not in self._connections:
+            self._connections[conn_id] = self.get_connection(conn_id)
+        return self._connections[conn_id]
+
+    def _warn_if_vertexai_field_ignored(self, extra: dict[str, Any]) -> None:
+        if extra.get("vertexai") is not None:
+            self.log.warning(
+                "The 'vertexai' connection field is ignored; Vertex AI vs. 
Generative Language "
+                "API mode is now selected via the model prefix 
('google-cloud:' vs. 'google:')."
+            )
+
+    def _get_provider_kwargs_for_model(self, conn: Connection, model_name: 
str) -> dict[str, Any]:
+        provider_name, _ = parse_model_id(model_name)
+        provider_kwargs_mapper = _PROVIDER_KWARGS_MAPPER_BY_MODEL_PREFIX.get(
+            provider_name, PydanticAIHook._get_provider_kwargs
+        )
+        extra = conn.extra_dejson
+        self._warn_if_vertexai_field_ignored(extra)

Review Comment:
   The mapper is chosen from the model prefix alone, with no reference to the 
connection or hook it belongs to, and the Bedrock/Vertex mappers were written 
for connection types that hide `password` and `host`, so they read only 
`extra`. On a generic `pydanticai` connection those two fields are visible and 
documented as the API key and the base URL, and they are now dropped for 
`bedrock:`, `google:` and `google-cloud:` (`azure:` and `openai:` are 
unaffected). Concretely: `model: "bedrock:us.anthropic.claude-opus-4-6-v1:0"` 
with a bearer token in API Key and a custom endpoint in Host forwarded both to 
`BedrockProvider` at d195f1e (it takes `api_key` and `base_url`) and now yields 
`{}`, so the task silently runs under the worker's AWS identity against the 
default endpoint, with no trace because the info log only fires for a non-empty 
dict. Since the mappers hide those fields on their own conn types, a 
`conn.password`/`conn.host` fallback when `extra` supplies neither can only 
fire for g
 eneric connections. Related: because dispatch never consults `self`, the 
`_get_provider_kwargs` docstring's "subclasses override this method" no longer 
describes what happens -- the three shipped subclasses are reached only because 
this dict re-registers them. Worth saying explicitly that the fix there is the 
wording (or declaring the prefixes on each hook class and populating the table 
from them), not restoring `self`-dispatch -- that would undo the cross-provider 
mapping `test_embedding_uses_model_provider_mapping_instead_of_hook_type` pins.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,6 +138,35 @@ def _get_provider_kwargs(
             kwargs["base_url"] = base_url
         return kwargs
 
+    def _get_cached_connection(self, conn_id: str) -> Connection:
+        if conn_id not in self._connections:
+            self._connections[conn_id] = self.get_connection(conn_id)
+        return self._connections[conn_id]
+
+    def _warn_if_vertexai_field_ignored(self, extra: dict[str, Any]) -> None:
+        if extra.get("vertexai") is not None:
+            self.log.warning(
+                "The 'vertexai' connection field is ignored; Vertex AI vs. 
Generative Language "
+                "API mode is now selected via the model prefix 
('google-cloud:' vs. 'google:')."
+            )
+
+    def _get_provider_kwargs_for_model(self, conn: Connection, model_name: 
str) -> dict[str, Any]:
+        provider_name, _ = parse_model_id(model_name)
+        provider_kwargs_mapper = _PROVIDER_KWARGS_MAPPER_BY_MODEL_PREFIX.get(
+            provider_name, PydanticAIHook._get_provider_kwargs
+        )
+        extra = conn.extra_dejson
+        self._warn_if_vertexai_field_ignored(extra)
+        return provider_kwargs_mapper(conn.password, conn.host, extra)
+
+    def _create_provider_factory(self, provider_kwargs: dict[str, Any]) -> 
Callable[[str], Any]:
+        def _create_provider(provider_name: str) -> Any:
+            if provider_name.startswith("gateway/"):

Review Comment:
   `gateway/` is a new user-facing model-id syntax and it is documented 
nowhere: grepping the provider's docs, `provider.yaml` and `src` finds it only 
at this branch and the import above. Meanwhile 
`docs/self_hosted_models.rst:259-281` documents a different AI-gateway wiring 
-- `host` as the gateway base URL, `password` as the bearer token, 
`extra["model"]` as `openai:<route>` -- so there are now two gateway mechanisms 
with incompatible `host` semantics and only the older one written down. The 
branch is also unreachable from the Bedrock and Vertex connection types: 
`parse_model_id` returns `"gateway/google"`, which matches no key in the mapper 
table, so the base mapper runs and those types hide `password`/`host`, giving 
`{}` and no `provider_factory` at all (verified: generic conn yields `{api_key, 
base_url}`, vertex and bedrock conns yield `{}`). Normalising the prefix before 
the table lookup would fix the second half.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -144,60 +184,85 @@ def get_conn(self) -> Model:
         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
-        )
+        conn = self._get_cached_connection(self.llm_conn_id)
+        extra = conn.extra_dejson
 
         model_name: str | KnownModelName = self.model_id or extra.get("model", 
"")
         if not model_name:
             raise ValueError(
                 "No model specified. Set model_id on the hook or the Model 
field on the connection."
             )
 
-        api_key: str | None = conn.password or None
-        base_url: str | None = conn.host or None
-
-        provider_kwargs = self._get_provider_kwargs(api_key, base_url, extra)
+        provider_kwargs = self._get_provider_kwargs_for_model(conn, model_name)
         if provider_kwargs:
-            _kwargs = provider_kwargs  # capture for closure
             self.log.info(
                 "Using explicit credentials for provider with model '%s': %s",
                 model_name,
                 list(provider_kwargs),
             )
-
-            def _provider_factory(pname: str) -> Any:
-                try:
-                    return infer_provider_class(pname)(**_kwargs)
-                except TypeError:
-                    self.log.warning(
-                        "Provider '%s' rejected kwargs %s; falling back to 
env-var auth",
-                        pname,
-                        list(_kwargs),
-                    )
-                    return infer_provider(pname)
-
-            self._model = infer_model(model_name, 
provider_factory=_provider_factory)
+            self._model = infer_model(
+                model_name,
+                
provider_factory=self._create_provider_factory(provider_kwargs),
+            )
             return self._model
 
         self._model = infer_model(model_name)
         return self._model
 
+    def get_embedder(self) -> Embedder:
+        """Return a pydantic-ai ``Embedder`` using this connection's 
credentials."""
+        if self._embedder is not None:
+            return self._embedder
+
+        conn = self._get_cached_connection(self.embed_conn_id)
+        extra: dict[str, Any] = conn.extra_dejson
+
+        embed_model_name: str = self.embed_model_id or 
extra.get("embed_model", "")
+        if not embed_model_name:
+            raise ValueError(
+                "No embedding model specified. Set embed_model_id on the hook 
or the embed_model field "
+                "on the connection."
+            )
+
+        provider_kwargs = self._get_provider_kwargs_for_model(conn, 
embed_model_name)
+        if provider_kwargs:
+            self.log.info(
+                "Using explicit credentials for provider with embedding model 
'%s': %s",
+                embed_model_name,
+                list(provider_kwargs),
+            )
+            embedding_model = infer_embedding_model(
+                embed_model_name,
+                
provider_factory=self._create_provider_factory(provider_kwargs),
+            )
+        else:
+            embedding_model = infer_embedding_model(embed_model_name)
+
+        self._embedder = Embedder(embedding_model, 
instrument=genai_instrumentation_settings())

Review Comment:
   `get_embedder()` takes no arguments, so `EmbeddingSettings` is unreachable 
-- at 2.31.1 `Embedder.__init__` is `(model, *, settings, defer_model_check, 
instrument)` and `EmbeddingSettings` carries 
`dimensions`/`truncate`/`extra_headers`/`extra_body`. `dimensions` is the one 
people reach for (text-embedding-3-small at 512 dims for a fixed-width vector 
column), and setting it today means building `Embedder` by hand, which forfeits 
exactly the credential resolution this PR adds. `create_agent` in this same 
class already solves the shape with `**agent_kwargs` and the `_UNSET` sentinel 
letting a caller-supplied `instrument` win over auto-instrumentation; `def 
get_embedder(self, **embedder_kwargs)` reusing that pattern would match it, 
with the cache either keyed on the kwargs or refusing them after the first call.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,6 +138,35 @@ def _get_provider_kwargs(
             kwargs["base_url"] = base_url
         return kwargs
 
+    def _get_cached_connection(self, conn_id: str) -> Connection:
+        if conn_id not in self._connections:
+            self._connections[conn_id] = self.get_connection(conn_id)
+        return self._connections[conn_id]
+
+    def _warn_if_vertexai_field_ignored(self, extra: dict[str, Any]) -> None:
+        if extra.get("vertexai") is not None:
+            self.log.warning(
+                "The 'vertexai' connection field is ignored; Vertex AI vs. 
Generative Language "
+                "API mode is now selected via the model prefix 
('google-cloud:' vs. 'google:')."
+            )
+
+    def _get_provider_kwargs_for_model(self, conn: Connection, model_name: 
str) -> dict[str, Any]:
+        provider_name, _ = parse_model_id(model_name)
+        provider_kwargs_mapper = _PROVIDER_KWARGS_MAPPER_BY_MODEL_PREFIX.get(
+            provider_name, PydanticAIHook._get_provider_kwargs
+        )
+        extra = conn.extra_dejson
+        self._warn_if_vertexai_field_ignored(extra)
+        return provider_kwargs_mapper(conn.password, conn.host, extra)
+
+    def _create_provider_factory(self, provider_kwargs: dict[str, Any]) -> 
Callable[[str], Any]:
+        def _create_provider(provider_name: str) -> Any:
+            if provider_name.startswith("gateway/"):
+                return 
gateway_provider(provider_name.removeprefix("gateway/"), **provider_kwargs)
+            return infer_provider_class(provider_name)(**provider_kwargs)

Review Comment:
   `infer_embedding_model` calls `provider_factory(provider_name)` 
unconditionally before its dispatch chain, including for the one branch that 
never uses a provider: `SentenceTransformerEmbeddingModel(model_name)` takes no 
`provider=`. Since `embed_conn_id` defaults to `llm_conn_id`, a hosted-chat 
connection with an API key plus `embed_model: 
"sentence-transformers:all-MiniLM-L6-v2"` now dies on `TypeError: 
SentenceTransformersProvider() takes no arguments` -- it reached the model at 
d195f1e, because `infer_provider("sentence-transformers")` succeeds and the old 
swallow routed to it, so hosted chat plus local embeddings on one connection is 
newly impossible. Separately, the base mapper's fixed `{api_key, base_url}` 
shape is wider than several constructors (`CohereProvider(api_key, 
cohere_client, http_client)` and `DeepSeekProvider` reject `base_url`, 
`LiteLLMProvider` spells it `api_base`, `SnowflakeProvider` has no `api_key`), 
and failing loudly there is the improvement I asked for
  -- but neither `infer_model` nor `infer_embedding_model` wraps the factory, 
so the user gets a bare `TypeError` from pydantic-ai internals naming no 
connection field. Re-raising as something that names the conn id and the kwargs 
supplied would keep the loud failure and restore the diagnostic the old warning 
carried.



##########
providers/common/ai/provider.yaml:
##########
@@ -322,6 +343,13 @@ connection-types:
           type:
             - string
             - 'null'
+      embed_model:
+        label: Embedding Model
+        description: "Google embedding model identifier (e.g. 
google-cloud:text-embedding-005)"

Review Comment:
   Still open from the last round: the three prefix tripwires 
(`test_conn_fields_model_description_prefix_is_valid_provider` and its two 
siblings) read only `conn-fields["model"]["description"]` for 
`pydanticai-vertex`, so the four new `embed_model` prefixes have no drift 
guard. All four resolve today -- I ran `openai:`, `azure:`, `bedrock:` and 
`google-cloud:` through `infer_embedding_model` on 2.31.1 -- so this is a guard 
rather than a live bug. It needs the same shape as 
`_assert_prefix_is_known_provider` with a wider suppress: an unrecognised 
prefix gives `ValueError: Unknown provider: <prefix>`, while a recognised one 
with no key on the test box gives `ImportError` or a credential `UserError`, so 
only the `ValueError` should fail the test.



##########
providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py:
##########
@@ -240,6 +251,279 @@ def test_get_conn_caches_model(self, mock_infer_model):
         mock_infer_model.assert_called_once()
 
 
+class TestPydanticAIHookGetEmbedder:
+    
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_embedding_model", 
autospec=True)
+    
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_provider_class", 
autospec=True)
+    def test_embedding_uses_model_provider_mapping_instead_of_hook_type(
+        self, mock_infer_provider_class, mock_infer_embedding_model
+    ):
+        mock_embedding_model = MagicMock(spec=EmbeddingModel)
+        mock_infer_embedding_model.return_value = mock_embedding_model
+        mock_provider = mock_infer_provider_class.return_value.return_value
+        hook = PydanticAIAzureHook(embed_conn_id="test_conn", 
embed_model_id="openai:text-embedding-3-small")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="pydanticai",
+            password="sk-test-key",
+            host="https://api.openai.com/v1";,
+        )
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            result = hook.get_embedder()
+
+        assert isinstance(result, Embedder)
+        assert result.model is mock_embedding_model
+        call_args = mock_infer_embedding_model.call_args
+        assert call_args.args == ("openai:text-embedding-3-small",)
+        provider_factory = call_args.kwargs["provider_factory"]
+        assert provider_factory("openai") is mock_provider
+        mock_infer_provider_class.return_value.assert_called_once_with(
+            api_key="sk-test-key", base_url="https://api.openai.com/v1";
+        )
+
+    @pytest.mark.parametrize(
+        ("provider_name", "extra", "expected_provider_kwargs"),
+        [
+            ("openai", {}, {"api_key": "connection-key", "base_url": 
"https://example.com"}),
+            (
+                "azure",
+                {"api_version": "2024-07-01-preview"},
+                {
+                    "api_key": "connection-key",
+                    "azure_endpoint": "https://example.com";,
+                    "api_version": "2024-07-01-preview",
+                },
+            ),
+            (
+                "azure-responses",
+                {"api_version": "2024-07-01-preview"},
+                {
+                    "api_key": "connection-key",
+                    "azure_endpoint": "https://example.com";,
+                    "api_version": "2024-07-01-preview",
+                },
+            ),
+            (
+                "bedrock",
+                {"region_name": "us-east-1"},
+                {"region_name": "us-east-1"},
+            ),
+            (
+                "google",
+                {
+                    "api_key": "extra-key",
+                    "base_url": "https://extra.example.com";,
+                    "project": "project",
+                },
+                {
+                    "api_key": "extra-key",
+                    "base_url": "https://extra.example.com";,
+                    "project": "project",

Review Comment:
   Two rows here pin the shapes the code gets wrong. The `google` row asserts 
`GoogleProvider(api_key=..., base_url=..., project="project")`, which the real 
class rejects, and the `bedrock` row asserts only `{"region_name": ...}` while 
the connection has `password` and `host` set -- so it encodes the discarded 
credentials as intended behaviour. Both pass only because 
`infer_provider_class` is patched, so `mock_infer_provider_class.return_value` 
swallows any kwargs; `Non-DB-prov::3.10:common.ai` is green at HEAD. 
`test_get_conn_vertexai_flag_is_not_forwarded` already shows the pattern that 
would have caught it -- a `FakeGoogleCloudProvider` carrying the real 
keyword-only signature -- and applying that to `GoogleProvider` for the 
`google` row would close it. Worth noting the `google` and `google-cloud` rows 
carry byte-identical expectations, so nothing here tests the one place those 
two classes actually diverge.



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