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


##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -540,3 +697,21 @@ def _get_provider_kwargs(
             )
 
         return kwargs
+
+
+_PROVIDER_CONNECTION_CONFIGS: dict[str | None, _ProviderConnectionConfig] = {
+    "azure": 
_ProviderConnectionConfig(PydanticAIAzureHook._get_provider_kwargs),
+    "azure-responses": 
_ProviderConnectionConfig(PydanticAIAzureHook._get_provider_kwargs),
+    "bedrock": _ProviderConnectionConfig(
+        PydanticAIBedrockHook._get_provider_kwargs,
+        ("api_key", "base_url", "region_name"),
+    ),
+    "google": _ProviderConnectionConfig(
+        PydanticAIVertexHook._get_google_provider_kwargs,
+        ("api_key", "base_url"),

Review Comment:
   `replacement_fields` drives a warning that inspects only `conn.password` and 
`conn.host`, and those two are `hidden_fields` on `pydanticai_vertex`, so on 
that connection type it can never fire. Meanwhile `_get_google_provider_kwargs` 
reads just `api_key` and `base_url` out of `extra` (correctly, `GoogleProvider` 
takes no `project`, `location` or `credentials` on 2.31.1). A Vertex connection 
that fills in the GCP Project and Location conn-fields and sets `model: 
"google:gemini-2.0-flash"` therefore gets `{}`, no `provider_factory`, and ADC, 
with two documented form fields discarded and an empty task log. 
`service_account_info` goes the same way.
   
   That combination is first-class here, not a misconfiguration: 
`test_get_conn_google_uses_google_provider_signature` exercises exactly it. And 
`main` was louder on this path, since the old self-dispatch produced `{project, 
location}`, hit `GoogleProvider`'s TypeError and logged `Provider 'google' 
rejected kwargs ['project', 'location']; falling back to env-var auth`.
   
   A second tuple on `_ProviderConnectionConfig` for the extra keys a mapper is 
known to drop, warned about next to the existing one, closes it inside the 
structure already here.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -540,3 +697,21 @@ def _get_provider_kwargs(
             )
 
         return kwargs
+
+
+_PROVIDER_CONNECTION_CONFIGS: dict[str | None, _ProviderConnectionConfig] = {
+    "azure": 
_ProviderConnectionConfig(PydanticAIAzureHook._get_provider_kwargs),
+    "azure-responses": 
_ProviderConnectionConfig(PydanticAIAzureHook._get_provider_kwargs),
+    "bedrock": _ProviderConnectionConfig(
+        PydanticAIBedrockHook._get_provider_kwargs,
+        ("api_key", "base_url", "region_name"),

Review Comment:
   The Bedrock tuple lists `api_key`, `base_url` and `region_name`, but the 
mapper also reads `aws_access_key_id`, `aws_secret_access_key`, 
`aws_session_token` and `profile_name`. The warning presents this list as the 
provider-specific values that replace the ignored ones, so a Bedrock user whose 
Password held an access key is steered to `api_key`, which is the bearer token 
and takes precedence over IAM keys once set. Listing all seven keeps the 
message from suggesting a different auth mode.



##########
providers/common/ai/docs/hooks/pydantic_ai.rst:
##########
@@ -56,6 +57,46 @@ The model can be specified at three levels (highest priority 
first):
     # Override with a specific model
     hook = PydanticAIHook(llm_conn_id="my_llm", 
model_id="anthropic:claude-opus-4-6")
 
+Embedding Models
+----------------
+
+Set ``embed_model_id`` on the hook or ``embed_model`` in the connection's 
extra JSON,
+then call ``get_embedder()``. Use ``embed_conn_id`` when the embedding 
provider uses
+different credentials or an endpoint from the LLM provider; it defaults to
+``llm_conn_id``. Different LLM and embedding providers require separate 
connections
+so credentials cannot be reused for the wrong provider. Equivalent OpenAI and 
Azure
+chat/response prefixes can share their provider's embedding connection. Local
+``sentence-transformers:`` embeddings can also share the LLM connection 
because they
+do not use provider credentials. The resolved ``Embedder`` is cached on the 
hook instance.
+
+.. code-block:: python
+
+    hook = PydanticAIHook(
+        llm_conn_id="my_llm",
+        embed_conn_id="my_embeddings",
+        embed_model_id="openai:text-embedding-3-small",
+    )
+    embedder = hook.get_embedder()
+    result = embedder.embed_query_sync("Apache Airflow orchestrates 
workflows.")
+    embedding = result.embeddings[0]
+
+Keyword arguments accepted by pydantic-ai's `Embedder constructor
+<https://ai.pydantic.dev/api/embeddings/#pydantic_ai.embeddings.Embedder.__init__>`__
+can be passed directly to ``get_embedder()``. These currently include 
``settings``,
+``defer_model_check``, and ``instrument``. Caller-supplied ``instrument`` takes
+precedence over Airflow's automatic instrumentation. Repeated calls with the 
same
+arguments return the cached instance; passing different arguments creates and 
caches
+a new instance.
+
+.. code-block:: python
+
+    from pydantic_ai.embeddings import EmbeddingSettings
+
+    embedder = hook.get_embedder(
+        settings=EmbeddingSettings(dimensions=512),
+        defer_model_check=False,

Review Comment:
   `defer_model_check=False` is a no-op through `get_embedder()`. 
`Embedder.__init__` does `self._model = model if defer_model_check else 
infer_embedding_model(model)`, and `infer_embedding_model` returns an 
`EmbeddingModel` instance unchanged, which is always what the hook passes. The 
flag only does anything when `Embedder` is given a model string. Dropping it 
from the example avoids teaching a setting that cannot fire here.



##########
providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py:
##########
@@ -239,6 +277,411 @@ def test_get_conn_caches_model(self, mock_infer_model):
         assert first is second
         mock_infer_model.assert_called_once()
 
+    @pytest.mark.parametrize(
+        ("model_name", "provider_name", "replacement_fields"),
+        [
+            (
+                "bedrock:us.anthropic.claude-opus-4-6-v1:0",
+                "bedrock",
+                ["api_key", "base_url", "region_name"],
+            ),
+            ("google:gemini-2.0-flash", "google", ["api_key", "base_url"]),
+            (
+                "google-cloud:gemini-2.0-flash",
+                "google-cloud",
+                ["api_key", "base_url", "project", "location"],
+            ),
+        ],
+    )
+    @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model", 
autospec=True)
+    def test_generic_connection_warns_when_password_and_host_are_ignored(
+        self, mock_infer_model, model_name, provider_name, replacement_fields
+    ):
+        mock_infer_model.return_value = MagicMock(spec=Model)
+        hook = PydanticAIHook(llm_conn_id="test_conn")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="pydanticai",
+            password="provider-key",
+            host="https://provider.example.com";,
+            extra=json.dumps({"model": model_name}),
+        )
+
+        with (
+            patch.object(hook, "get_connection", return_value=conn),
+            patch.object(hook.log, "warning", autospec=True) as mock_warning,
+        ):
+            hook.get_conn()
+
+        mock_warning.assert_called_once_with(

Review Comment:
   This is one assertion away from also pinning extra-only precedence, which is 
the thing round 3 settled. Nothing in the file currently fails if the Google 
mappers grow the fill-in-when-absent `conn.password` / `conn.host` fallback: 
the two direct mapper tests pass `None, None` for those parameters, the 
`google` and `google-cloud` rows of 
`test_embedding_credentials_are_mapped_by_model_provider` already carry 
`api_key` and `base_url` in `extra` so a fill-if-absent branch never fires, and 
this test sets the connection fields but checks only the warning.
   
   All three rows here limit `extra` to `model`, so each resolves to `{}` and 
no `provider_factory` today. Adding 
`mock_infer_model.assert_called_once_with(model_name)` below covers bedrock, 
google and google-cloud in one line and turns that mutation red.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,6 +159,96 @@ 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 _get_cached_connection_extra_dejson(self, conn_id: str) -> dict[str, 
Any]:
+        if conn_id not in self._connection_extra_dejson:
+            conn = self._get_cached_connection(conn_id)
+            self._connection_extra_dejson[conn_id] = conn.extra_dejson
+        return self._connection_extra_dejson[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, extra: dict[str, Any]
+    ) -> dict[str, Any]:
+        provider_name, _ = parse_model_id(model_name)
+        provider_config = _PROVIDER_CONNECTION_CONFIGS.get(provider_name)
+        self._warn_if_vertexai_field_ignored(extra)
+        if provider_config is None:
+            return PydanticAIHook._get_provider_kwargs(conn.password, 
conn.host, extra)
+        if provider_config.replacement_fields:
+            ignored_fields = [
+                field for field, value in (("password", conn.password), 
("host", conn.host)) if value
+            ]
+            if ignored_fields:
+                self.log.warning(
+                    "Connection fields are ignored for provider %r on 
connection %r; "
+                    "ignored fields: %s; configure these provider-specific 
values in extra: %s",
+                    provider_name,
+                    conn.conn_id,
+                    ignored_fields,
+                    list(provider_config.replacement_fields),
+                )
+        return provider_config.get_kwargs(conn.password, conn.host, extra)
+
+    def _get_provider_factory_for_model(
+        self, conn: Connection, model_name: str, extra: dict[str, Any]
+    ) -> Callable[[str], Any] | None:
+        provider_name, _ = parse_model_id(model_name)
+        if provider_name == "sentence-transformers":
+            return None
+
+        provider_kwargs = self._get_provider_kwargs_for_model(conn, 
model_name, extra)
+        if not provider_kwargs:
+            return None
+
+        self.log.info(
+            "Using explicit connection credentials for model '%s': %s",
+            model_name,
+            list(provider_kwargs),
+        )
+
+        def create_provider(provider: str) -> Any:
+            try:
+                return infer_provider_class(provider)(**provider_kwargs)
+            except TypeError as e:
+                raise TypeError(
+                    f"Provider {provider!r} rejected connection 
{conn.conn_id!r} fields "
+                    f"mapped to kwargs {sorted(provider_kwargs)}"

Review Comment:
   This names the kwargs that were supplied but not the one the provider 
actually rejected, and the cause does not reach the user. `test_connection` at 
:461 returns `str(e)`, not a formatted traceback, so the Test button in the UI 
shows only this line. Reproduced against 2.31.1 with a generic connection 
carrying an API key and a Host, `embed_model: cohere:embed-v4.0`:
   
   ```
   str(e):        Provider 'cohere' rejected connection 'my_conn' fields mapped 
to kwargs ['api_key', 'base_url']
   str(e.__cause__):  CohereProvider.__init__() got an unexpected keyword 
argument 'base_url'
   ```
   
   Both kwargs came from the connection and only `base_url` is wrong, so the 
message as rendered points at the key. `self_hosted_models.rst:320`, added by 
this PR, tells the reader the TypeError "identifies the rejected connection 
fields", which describes the cause line rather than this one. Appending `: {e}` 
to the message restores it.
   
   The same one-liner covers the other half: the `try` wraps the whole 
constructor call, so a TypeError raised inside a provider's body (an SDK 
version skew, say) currently gets relabelled as a connection-field problem with 
no hint otherwise.



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -127,6 +159,96 @@ 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 _get_cached_connection_extra_dejson(self, conn_id: str) -> dict[str, 
Any]:
+        if conn_id not in self._connection_extra_dejson:
+            conn = self._get_cached_connection(conn_id)
+            self._connection_extra_dejson[conn_id] = conn.extra_dejson
+        return self._connection_extra_dejson[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, extra: dict[str, Any]
+    ) -> dict[str, Any]:
+        provider_name, _ = parse_model_id(model_name)
+        provider_config = _PROVIDER_CONNECTION_CONFIGS.get(provider_name)
+        self._warn_if_vertexai_field_ignored(extra)
+        if provider_config is None:
+            return PydanticAIHook._get_provider_kwargs(conn.password, 
conn.host, extra)
+        if provider_config.replacement_fields:
+            ignored_fields = [
+                field for field, value in (("password", conn.password), 
("host", conn.host)) if value
+            ]
+            if ignored_fields:
+                self.log.warning(
+                    "Connection fields are ignored for provider %r on 
connection %r; "
+                    "ignored fields: %s; configure these provider-specific 
values in extra: %s",
+                    provider_name,
+                    conn.conn_id,
+                    ignored_fields,
+                    list(provider_config.replacement_fields),
+                )
+        return provider_config.get_kwargs(conn.password, conn.host, extra)
+
+    def _get_provider_factory_for_model(
+        self, conn: Connection, model_name: str, extra: dict[str, Any]
+    ) -> Callable[[str], Any] | None:
+        provider_name, _ = parse_model_id(model_name)
+        if provider_name == "sentence-transformers":
+            return None
+
+        provider_kwargs = self._get_provider_kwargs_for_model(conn, 
model_name, extra)
+        if not provider_kwargs:
+            return None
+
+        self.log.info(
+            "Using explicit connection credentials for model '%s': %s",
+            model_name,
+            list(provider_kwargs),
+        )
+
+        def create_provider(provider: str) -> Any:
+            try:
+                return infer_provider_class(provider)(**provider_kwargs)
+            except TypeError as e:
+                raise TypeError(
+                    f"Provider {provider!r} rejected connection 
{conn.conn_id!r} fields "
+                    f"mapped to kwargs {sorted(provider_kwargs)}"
+                ) from e
+
+        return create_provider
+
+    def _validate_embedding_connection_provider(self, embed_model_name: str, 
extra: dict[str, Any]) -> None:
+        if self.embed_conn_id != self.llm_conn_id:
+            return
+
+        llm_model_name = self.model_id or extra.get("model", "")
+        if not llm_model_name:
+            return
+
+        llm_provider, _ = parse_model_id(llm_model_name)
+        embed_provider, _ = parse_model_id(embed_model_name)

Review Comment:
   `parse_model_id` returns `(None, name)` for a value with no prefix, so 
`{"model": "openai:gpt-4o", "embed_model": "text-embedding-3-small"}` falls 
into the mismatch branch and reports `configures different LLM and embedding 
providers ('openai' and None). Set embed_conn_id to a separate connection for 
the embedding provider.` A separate connection does not help; drop the `model` 
key and pydantic-ai gives the accurate `ValueError: You must provide a provider 
prefix when specifying an embedding model name` instead.
   
   The prefixless form is a plausible thing to type into this field: the 
`llamaindex` conn type in this same `provider.yaml` documents `embed_model` as 
`text-embedding-3-small`. `if embed_provider is None: return` hands it back to 
pydantic-ai.



##########
providers/common/ai/docs/connections/pydantic_ai.rst:
##########
@@ -67,14 +88,24 @@ Host (optional)
 
 Extra (JSON, optional)
     A JSON object with additional configuration. Programmatic users can set the
-    model directly in extra:
+    LLM and embedding models directly in extra:
 
     .. code-block:: json
 
-        {"model": "openai:gpt-5.6-sol"}
+        {
+            "model": "openai:gpt-5.6-sol",
+            "embed_model": "openai:text-embedding-3-small"
+        }
 
-    When using the UI, the "Model" field above writes to this same location
-    automatically.
+    When using the UI, the "Model" and "Embedding Model" fields above write to
+    this same location automatically.
+
+    Bedrock-specific fields include ``api_key``, ``base_url``, ``region_name``,
+    AWS credentials and profile fields, and read/connect timeouts. ``google:``
+    accepts ``api_key`` and ``base_url``; ``google-cloud:`` additionally 
accepts
+    ``project``, ``location``, and ``service_account_info``. See the dedicated
+    :doc:`pydantic_ai_bedrock` and :doc:`pydantic_ai_vertex` connection pages 
for

Review Comment:
   This sends the reader to two pages that now say the opposite of what the 
hook does. `pydantic_ai_bedrock.rst:26-29` and `pydantic_ai_vertex.rst:25-29` 
both open with those credential shapes being "none of which fit the plain 
``api_key`` + ``base_url`` shape that the generic :doc:`pydantic_ai` connection 
assumes", which held while the mapper was picked by hook class. With the prefix 
table, a generic `pydanticai` connection carrying `model: "bedrock:..."` plus 
`region_name` and IAM keys in `extra` builds `BedrockProvider` with all of 
them. What is left of the difference is that the dedicated types give those 
fields their own form inputs and hide `password`/`host`, not that the generic 
one cannot reach the provider. Worth a sentence on each opener, since this new 
cross-reference is what will send people there.



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