This is an automated email from the ASF dual-hosted git repository.

Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new dfe9cc6ef3c Add Ollama model readiness check to PydanticAIHook (#75369)
dfe9cc6ef3c is described below

commit dfe9cc6ef3c1ab508ae06e7e945d62d810ab23df
Author: Wei Lee <[email protected]>
AuthorDate: Tue Nov 10 10:28:16 2026 +0900

    Add Ollama model readiness check to PydanticAIHook (#75369)
    
    * Add Ollama model readiness check to PydanticAIHook
    
    - Add PydanticAIHook.ensure_model_ready(timeout, poll_interval), an opt-in 
method that
      waits for the primary connection's Ollama model to be loaded before a 
task builds its
      agent. Other model prefixes raise NotImplementedError.
    - Add LocalModelBackend protocol and OllamaBackend (/api/ps, /api/generate) 
in
      hooks/local_model_backends.py.
    - Every request is bounded by the remaining timeout and timeouts surface as
      TimeoutError; after the load request, connection failures and 5xx 
responses are
      retried until the timeout is used up.
    - Declare httpx2 as a common.ai dependency.
    - Document the method and its limitations in self_hosted_models.rst.
    
    * Wrap an over-long line in the Ollama readiness docs
    
    * Check readiness once after loading instead of polling
    
    OllamaBackend.load() already blocks until the model is loaded, so polling 
afterwards has no purpose. Do one readiness check, one blocking load and one 
confirming check, and drop poll_interval. Correct the keep_alive comment in 
OllamaBackend.load().
    
    * Let ensure_model_ready take any backend with is_ready and load
    
    Add a ModelReadinessBackend Protocol and a backend= argument so any server 
with is_ready() and load() works. Ollama stays the built-in default, picked by 
a resolver.
    
    * Load Ollama models with a generate-only request and a backend factory
    
    The /api/ps listing is not a reliable readiness signal while a model is
    still loading, so ensure_model_ready now only sends the empty-prompt
    /api/generate request and hands the whole timeout to load(). is_ready,
    normalize_ollama_model_name and the RuntimeError path are removed.
    
    backend= is replaced by backend_factory=, which the hook calls with the
    resolved model_name and the connection's base_url so a custom backend, or
    OllamaBackend behind an openai: model, gets the same values the hook used.
    The OllamaBackend tests now assert the timeout given to the client and the
    full request body.
    
    * Let ensure_model_ready fall back to OLLAMA_BASE_URL
    
    - The built-in Ollama path takes the connection host, then the
      OLLAMA_BASE_URL environment variable, as pydantic-ai's OllamaProvider
      does, and raises ValueError only when both are empty.
    - Cover a model set only in the connection extra, and a bare model name
      with the model_provider extra.
    
    * Document keep_alive and upstream loading for Ollama readiness
    
    - Explain how @task.llm and AgentOperator users call ensure_model_ready
      from an upstream task, and that the load request sends no keep_alive,
      so Ollama may unload the model after its own keep-alive time.
    - Say that a custom backend_factory receives the connection host or None
      and does not read OLLAMA_BASE_URL.
    - Build the backend_factory test double with create_autospec so the
      keyword signature is checked.
    
    * Type the readiness backend factory and resolve a missing Ollama URL in 
the backend
    
    - OllamaBackend accepts base_url=None and falls back to OLLAMA_BASE_URL,
      raising ValueError when neither is set, so backend_factory=OllamaBackend
      works for connections without a host
    - backend_factory is typed as a ModelReadinessBackendFactory protocol with
      keyword-only model_name and base_url
    - ensure_model_ready splits the model name once
    - test stub for the factory no longer has an empty body
    
    * Name the model and the server error when Ollama fails to load it
    
    - OllamaBackend.load re-raises HTTPStatusError with the model name and the
      error field Ollama returned, instead of only the status line and URL
    - A body that is not JSON, has no error field, or is blank falls back to the
      response text, then to the reason phrase
    
    * Point readiness docstrings at the self-hosted models guide instead of 
repeating it
    
    - ModelReadinessBackend, ModelReadinessBackendFactory and ensure_model_ready
      keep only their API contract and link to the guide with :ref:
    - Add anchors for the Ollama waiting and custom backend sections of the 
guide
    - Move the base_url contract for backend factories into the guide
    
    * Explain when Ollama warm-up helps and which base URL variable applies
    
    - Say that the warm-up matters when model_settings sets a timeout shorter
      than pydantic-ai's default HTTP read timeout of 600 seconds
    - Note that a warm-up task and the LLM task can reach different Ollama
      servers with per-worker Ollama or replicas behind a load balancer
    - Limit the OLLAMA_BASE_URL fallback to ollama: models and point openai:
      models at OPENAI_BASE_URL
---
 providers/common/ai/README.rst                     |   1 +
 .../common/ai/docs/connections/pydantic_ai.rst     |   5 +-
 providers/common/ai/docs/index.rst                 |   1 +
 providers/common/ai/docs/self_hosted_models.rst    | 103 +++++++++++
 providers/common/ai/provider.yaml                  |   1 +
 providers/common/ai/pyproject.toml                 |   1 +
 .../providers/common/ai/get_provider_info.py       |   5 +-
 .../airflow/providers/common/ai/hooks/ollama.py    |  98 ++++++++++
 .../providers/common/ai/hooks/pydantic_ai.py       | 106 ++++++++++-
 .../ai/tests/unit/common/ai/hooks/test_ollama.py   | 139 ++++++++++++++
 .../tests/unit/common/ai/hooks/test_pydantic_ai.py | 204 ++++++++++++++++++++-
 uv.lock                                            |   2 +
 12 files changed, 658 insertions(+), 8 deletions(-)

diff --git a/providers/common/ai/README.rst b/providers/common/ai/README.rst
index 2fc139628d4..cd4d21bf72f 100644
--- a/providers/common/ai/README.rst
+++ b/providers/common/ai/README.rst
@@ -56,6 +56,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.22.0``
 ``apache-airflow-providers-standard``       ``>=1.20.0``
+``httpx2``                                  ``>=2.7``
 ``pydantic-ai-slim``                        ``>=2.36.0``
 ``structlog``                               ``>=24.2.0``
 ==========================================  ==================
diff --git a/providers/common/ai/docs/connections/pydantic_ai.rst 
b/providers/common/ai/docs/connections/pydantic_ai.rst
index 831e0ac2b54..7ae9250a64d 100644
--- a/providers/common/ai/docs/connections/pydantic_ai.rst
+++ b/providers/common/ai/docs/connections/pydantic_ai.rst
@@ -61,7 +61,10 @@ API Key (Password field)
     ``GOOGLE_APPLICATION_CREDENTIALS``).
 
 Host (optional)
-    Base URL for the provider's API. Only needed for custom endpoints:
+    Base URL for the provider's API. Only needed for custom endpoints. For 
``ollama:``
+    models it can instead come from the ``OLLAMA_BASE_URL`` environment 
variable;
+    ``openai:`` models, including the Ollama example below that uses
+    ``openai:llama3``, read ``OPENAI_BASE_URL`` instead:
 
     - Ollama: ``http://localhost:11434/v1``
     - vLLM: ``http://localhost:8000/v1``
diff --git a/providers/common/ai/docs/index.rst 
b/providers/common/ai/docs/index.rst
index d94b5b20574..cc627cf2842 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -188,6 +188,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.22.0``
 ``apache-airflow-providers-standard``       ``>=1.20.0``
+``httpx2``                                  ``>=2.7``
 ``pydantic-ai-slim``                        ``>=2.36.0``
 ``structlog``                               ``>=24.2.0``
 ==========================================  ==================
diff --git a/providers/common/ai/docs/self_hosted_models.rst 
b/providers/common/ai/docs/self_hosted_models.rst
index 44fcaedce23..72757f7e50b 100644
--- a/providers/common/ai/docs/self_hosted_models.rst
+++ b/providers/common/ai/docs/self_hosted_models.rst
@@ -257,6 +257,109 @@ model doesn't have to guess a path. That matters because 
``HttpHook`` raises
 on any response outside the 2xx/3xx range: if the model guessed wrong, the
 task would fail outright rather than the agent recovering gracefully.
 
+.. _self_hosted_models_ensure_model_ready:
+
+Waiting for an Ollama model to load
+-------------------------------------
+
+Ollama loads a model into memory on the first request. pydantic-ai's default
+HTTP read timeout is 600 seconds, longer than the 300 seconds used below, so 
with
+default settings the agent's first request waits for the load itself. Loading
+ahead of time matters when ``model_settings`` sets a shorter ``timeout``: the 
first
+request then times out before the model has loaded. In that case, call
+:meth:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook.ensure_model_ready`
+before the task builds its agent. It sends Ollama a load request for this
+connection's model and waits until Ollama answers, which it does once the model
+is available (right away if it is already loaded):
+
+.. code-block:: python
+
+    from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
+
+    hook = PydanticAIHook(llm_conn_id="pydanticai_default")
+    hook.ensure_model_ready(timeout=300)
+    agent = hook.create_agent(instructions="Answer briefly.")
+
+Nothing calls it for you; it is opt-in. It has these limits:
+
+- Only Ollama is built in. For any other model prefix pass ``backend_factory=``
+  (see below); without it, ``NotImplementedError`` is raised.
+- Only the hook's own connection is checked. Models behind 
``fallback_conn_ids``
+  are not loaded.
+- The call blocks and holds the worker slot for the whole wait.
+- The built-in Ollama backend does not send the connection's password, so an 
Ollama
+  server behind authentication is not supported.
+
+``@task.llm`` and ``AgentOperator`` build their own hook from ``llm_conn_id`` 
and
+their ``model_id``, so call ``ensure_model_ready`` from an upstream task with
+the same connection and, if the downstream task sets ``model_id``, the same
+``model_id``. Otherwise the upstream task loads the connection's own model:
+
+.. code-block:: python
+
+    from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
+    from airflow.sdk import task
+
+
+    @task
+    def load_model():
+        hook = PydanticAIHook(llm_conn_id="pydanticai_default", 
model_id="ollama:llama3")
+        hook.ensure_model_ready(timeout=300)
+
+The load request does not send ``keep_alive``, so Ollama unloads the model 
after
+its own keep-alive time (5 minutes by default) like any other model. If the
+downstream LLM task can wait in the queue longer than that, raise the server's
+default with the ``OLLAMA_KEEP_ALIVE`` environment variable.
+
+The upstream task and the LLM task must reach the same Ollama server for the
+load to help. With one Ollama on each worker (a ``localhost`` connection) or 
several
+replicas behind a load balancer, the load request can reach a different server
+than the LLM call, and then it has no effect on that call.
+
+``timeout`` is passed as is to the load request. Running out ends in a
+``TimeoutError`` naming the model, or the server when it cannot be reached.
+Nothing is retried, so a wrong host, a server error or a request timing out is
+raised right away.
+
+.. _self_hosted_models_readiness_backend:
+
+Using your own readiness backend
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``ensure_model_ready`` accepts ``backend_factory=``, a callable that is called
+with the keyword arguments ``model_name`` (the model name without its provider
+prefix) and ``base_url`` (the connection's host, or ``None``) and returns any
+object with a keyword-only ``load(*, timeout)`` method, described by
+:class:`~airflow.providers.common.ai.hooks.pydantic_ai.ModelReadinessBackend`.
+``load`` should return once the model is available. With ``backend_factory=``,
+the model prefix and the connection host are not checked, and errors are raised
+as is. The ``base_url`` the factory receives is only the connection's host, 
never
+``OLLAMA_BASE_URL``; the factory decides what to do when it is ``None``.
+
+.. code-block:: python
+
+    class MyServerBackend:
+        def __init__(self, model_name: str, base_url: str | None) -> None: ...
+
+        def load(self, *, timeout: float) -> None: ...
+
+
+    hook.ensure_model_ready(backend_factory=MyServerBackend)
+
+The prefix is not used to detect the server, because ``openai:<model>`` can
+point at Ollama's ``/v1`` endpoint as well as at any other OpenAI-compatible
+server. When it points at Ollama, pass ``OllamaBackend`` as the factory
+(``base_url`` may keep its ``/v1`` suffix). When the connection has no host,
+``OllamaBackend`` uses the ``OLLAMA_BASE_URL`` environment variable, and raises
+``ValueError`` if that is not set either:
+
+.. code-block:: python
+
+    from airflow.providers.common.ai.hooks.ollama import OllamaBackend
+
+    hook = PydanticAIHook(llm_conn_id="pydanticai_default", 
model_id="openai:llama3")
+    hook.ensure_model_ready(backend_factory=OllamaBackend)
+
 Routing through an AI gateway
 --------------------------------
 
diff --git a/providers/common/ai/provider.yaml 
b/providers/common/ai/provider.yaml
index eaffa676ab8..63c917cb7a1 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -92,6 +92,7 @@ hooks:
   - integration-name: Pydantic AI
     python-modules:
       - airflow.providers.common.ai.hooks.pydantic_ai
+      - airflow.providers.common.ai.hooks.ollama
   - integration-name: MCP Server
     python-modules:
       - airflow.providers.common.ai.hooks.mcp
diff --git a/providers/common/ai/pyproject.toml 
b/providers/common/ai/pyproject.toml
index ca44e12fdc2..9dcff8706a4 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -68,6 +68,7 @@ dependencies = [
     "apache-airflow>=2.11.0",
     "apache-airflow-providers-common-compat>=1.22.0",
     "apache-airflow-providers-standard>=1.20.0",
+    "httpx2>=2.7",
     # 2.33.0 is the first release that works with anthropic>=1: it moved to 
httpx2 alongside
     # the SDK and stopped passing temperature/top_p/top_k as messages.create() 
kwargs, both
     # of which raise TypeError on 2.31 and earlier. The cost API this provider 
relies on
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py 
b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
index 331957e8030..6c172a2a719 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
@@ -92,7 +92,10 @@ def get_provider_info():
         "hooks": [
             {
                 "integration-name": "Pydantic AI",
-                "python-modules": 
["airflow.providers.common.ai.hooks.pydantic_ai"],
+                "python-modules": [
+                    "airflow.providers.common.ai.hooks.pydantic_ai",
+                    "airflow.providers.common.ai.hooks.ollama",
+                ],
             },
             {"integration-name": "MCP Server", "python-modules": 
["airflow.providers.common.ai.hooks.mcp"]},
             {
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/hooks/ollama.py 
b/providers/common/ai/src/airflow/providers/common/ai/hooks/ollama.py
new file mode 100644
index 00000000000..110443f59f4
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/ollama.py
@@ -0,0 +1,98 @@
+# 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.
+from __future__ import annotations
+
+import os
+
+import httpx2
+
+
+def build_ollama_native_root(base_url: str) -> str:
+    """Return the server root of an Ollama URL, dropping a trailing ``/v1`` 
(the OpenAI-compatible path)."""
+    return base_url.rstrip("/").removesuffix("/v1")
+
+
+class OllamaBackend:
+    """
+    Load control for a model served by Ollama, through its native 
``/api/generate`` endpoint.
+
+    :param base_url: Ollama URL; a trailing ``/v1`` is dropped to reach the 
native API. When ``None``
+        or empty, the ``OLLAMA_BASE_URL`` environment variable is used.
+    :param model_name: Model name without the ``ollama:`` prefix.
+    :param transport: Optional httpx2 transport, mainly to stub the server in 
tests.
+
+    :raises ValueError: If *base_url* is not given and ``OLLAMA_BASE_URL`` is 
not set.
+
+    Known limitation: the connection password is not sent, so no 
``Authorization`` header is
+    added and servers behind authentication are not supported.
+    """
+
+    def __init__(
+        self,
+        base_url: str | None,
+        model_name: str,
+        *,
+        transport: httpx2.BaseTransport | None = None,
+    ) -> None:
+        if not (resolved_url := base_url or os.getenv("OLLAMA_BASE_URL")):
+            raise ValueError(
+                "OllamaBackend needs a base_url or the OLLAMA_BASE_URL 
environment variable; neither is set"
+            )
+        self.root = build_ollama_native_root(resolved_url)
+        self.model_name = model_name
+        self._transport = transport
+
+    def load(self, *, timeout: float) -> None:
+        """
+        Load the model, waiting at most *timeout* seconds.
+
+        :raises TimeoutError: If the server cannot be reached or does not 
finish loading within *timeout*.
+        :raises httpx2.HTTPStatusError: If the server answers with an error 
status; the message names the
+            model and carries the server's ``error`` text (the response body 
when it has none).
+        """
+        # A /api/generate request with no prompt and stream=False returns once 
the model is
+        # available. keep_alive only controls how long the model stays loaded 
afterwards.
+        try:
+            with httpx2.Client(transport=self._transport, timeout=timeout) as 
client:
+                response = client.post(
+                    f"{self.root}/api/generate", json={"model": 
self.model_name, "stream": False}
+                )
+                response.raise_for_status()
+        except httpx2.HTTPStatusError as e:
+            raise httpx2.HTTPStatusError(
+                f"Ollama could not load model '{self.model_name}' 
({e.response.status_code}): "
+                f"{self._extract_error_detail(e.response)}",
+                request=e.request,
+                response=e.response,
+            ) from e
+        except httpx2.ConnectTimeout as e:
+            raise TimeoutError(f"Could not reach Ollama at {self.root} within 
{timeout} seconds.") from e
+        except httpx2.TimeoutException as e:
+            raise TimeoutError(
+                f"Ollama did not finish loading model '{self.model_name}' 
within {timeout} seconds."
+            ) from e
+
+    @staticmethod
+    def _extract_error_detail(response: httpx2.Response) -> str:
+        """Return Ollama's ``error`` field, or the body cut to a short length 
when there is none."""
+        try:
+            body = response.json()
+        except ValueError:
+            body = None
+        if isinstance(body, dict) and isinstance(error := body.get("error"), 
str) and error:
+            return error
+        return response.text.strip()[:200] or response.reason_phrase
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py 
b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
index 90d3a3736ba..645f4a41bbd 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
@@ -20,9 +20,11 @@ import asyncio
 import concurrent.futures
 import contextvars
 import functools
+import math
 import re
+import time
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, TypeVar, overload
+from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload
 
 import pydantic_ai
 from pydantic_ai import Agent
@@ -32,6 +34,7 @@ from pydantic_ai.models.fallback import FallbackModel
 from pydantic_ai.providers import infer_provider_class
 from typing_extensions import ParamSpec
 
+from airflow.providers.common.ai.hooks.ollama import OllamaBackend
 from airflow.providers.common.ai.observability import 
genai_instrumentation_settings
 from airflow.providers.common.ai.utils.toolset_base import MaskingCapability
 from airflow.providers.common.compat.module_loading import import_string
@@ -45,6 +48,32 @@ OutputT = TypeVar("OutputT")
 P = ParamSpec("P")
 R = TypeVar("R")
 
+
+class ModelReadinessBackend(Protocol):
+    """
+    Server-specific half of :meth:`PydanticAIHook.ensure_model_ready`.
+
+    Any object with this method works; it does not need to subclass this class.
+
+    ``load`` returns once the model is available, waiting at most *timeout* 
seconds, and raises
+    ``TimeoutError`` when it exceeds *timeout*. See 
:ref:`self_hosted_models_readiness_backend`.
+    """
+
+    def load(self, *, timeout: float) -> None: ...
+
+
+class ModelReadinessBackendFactory(Protocol):
+    """
+    Builds the :class:`ModelReadinessBackend` for 
:meth:`PydanticAIHook.ensure_model_ready`.
+
+    Called with the ``model_name`` without its provider prefix and the 
connection host as
+    ``base_url`` (``None`` when the connection has no host). ``OllamaBackend`` 
is one.
+    See :ref:`self_hosted_models_readiness_backend`.
+    """
+
+    def __call__(self, *, model_name: str, base_url: str | None) -> 
ModelReadinessBackend: ...
+
+
 # Sentinel distinguishing "caller did not pass ``instrument``" from an explicit
 # ``instrument=None`` / ``instrument=False`` (which mean "do not instrument, 
and
 # do not auto-enable it either").
@@ -527,10 +556,7 @@ class PydanticAIHook(BaseHook):
             model_name = forwarded_model_id
             forwarded = True
         if not model_name:
-            raise ValueError(
-                f"No model specified for connection '{self.llm_conn_id}'. Set 
model_id on the "
-                "hook or the Model field on the connection."
-            )
+            raise self._build_missing_model_error()
         model_name = self._qualify_model_name(
             model_name,
             forwarded_from_conn_id=forwarded_from_conn_id if forwarded else 
None,
@@ -562,6 +588,76 @@ class PydanticAIHook(BaseHook):
 
         return infer_model(model_name)
 
+    def _build_missing_model_error(self) -> ValueError:
+        return ValueError(
+            f"No model specified for connection '{self.llm_conn_id}'. Set 
model_id on the "
+            "hook or the Model field on the connection."
+        )
+
+    def _build_readiness_backend(
+        self, model_name: str, backend_factory: ModelReadinessBackendFactory | 
None
+    ) -> ModelReadinessBackend:
+        """
+        Build the readiness backend for the provider-prefixed *model_name*.
+
+        *backend_factory* is called with the unprefixed ``model_name`` and the 
connection's
+        ``base_url``; without it only Ollama has a built-in backend.
+        """
+        provider_prefix, sep, rest = model_name.partition(":")
+        if backend_factory is None and provider_prefix != "ollama":
+            raise NotImplementedError(
+                f"ensure_model_ready has no built-in readiness backend for 
'{model_name}' on "
+                f"connection '{self.llm_conn_id}'; only Ollama is built in. 
Pass backend_factory= a "
+                "callable taking model_name= and base_url= that returns an 
object with load(); "
+                "OllamaBackend is available for a model that Ollama serves."
+            )
+        conn, _ = self._get_conn_and_extra()
+        base_url = conn.host or None
+        unprefixed_name = rest if sep else model_name
+        if backend_factory is not None:
+            return backend_factory(model_name=unprefixed_name, 
base_url=base_url)
+        return OllamaBackend(base_url, unprefixed_name)
+
+    def ensure_model_ready(
+        self,
+        *,
+        timeout: float = 300.0,
+        backend_factory: ModelReadinessBackendFactory | None = None,
+    ) -> None:
+        """
+        Block until this connection's local model is loaded, asking the server 
to load it if needed.
+
+        Opt-in, blocks the worker slot, and loads only this hook's own 
connection. Details and
+        limits: :ref:`self_hosted_models_ensure_model_ready`.
+
+        :param timeout: Seconds to wait for the load request, passed to the 
backend as is.
+        :param backend_factory: Callable that takes the keyword arguments 
``model_name`` (the
+            resolved name without its provider prefix) and ``base_url`` (the 
connection host, or
+            ``None``) and returns an object with ``load()`` (see 
ModelReadinessBackend). When
+            given, the model prefix and connection host checks are skipped; a 
missing model still
+            raises ``ValueError``. See 
:ref:`self_hosted_models_readiness_backend`.
+        :raises ValueError: If *timeout* is not a positive finite number or no 
model is configured;
+            also from the default ``OllamaBackend`` when neither the 
connection host nor the
+            ``OLLAMA_BASE_URL`` environment variable is set.
+        :raises NotImplementedError: If *backend_factory* is not given and the 
model's provider
+            prefix has no built-in backend (only ``ollama`` has one).
+        :raises TimeoutError: If a request times out.
+        :raises httpx2.TransportError: If a request cannot reach the server.
+        :raises httpx2.HTTPStatusError: If the server answers with an error 
status. The default
+            ``OllamaBackend`` words the message with the model name and the 
server's error text.
+        """
+        if not math.isfinite(timeout) or timeout <= 0:
+            raise ValueError(f"timeout must be positive and finite, got 
{timeout}.")
+        if not (configured_name := self._get_configured_model_name()):
+            raise self._build_missing_model_error()
+        model_name = self._qualify_model_name(configured_name)
+        backend = self._build_readiness_backend(model_name, backend_factory)
+
+        started = time.monotonic()
+        self.log.info("Loading model '%s' for connection '%s'", model_name, 
self.llm_conn_id)
+        backend.load(timeout=timeout)
+        self.log.info("Model '%s' is ready after %.1f seconds", model_name, 
time.monotonic() - started)
+
     def _get_fallback_conn_ids(self) -> list[str]:
         """
         Return the configured fallback connection IDs, hook argument winning 
over the extra.
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_ollama.py 
b/providers/common/ai/tests/unit/common/ai/hooks/test_ollama.py
new file mode 100644
index 00000000000..01a7dbea76d
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_ollama.py
@@ -0,0 +1,139 @@
+# 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.
+from __future__ import annotations
+
+import json
+
+import httpx2
+import pytest
+
+from airflow.providers.common.ai.hooks.ollama import OllamaBackend, 
build_ollama_native_root
+
+
+def _build_backend(
+    handler, base_url: str = "http://ollama:11434/v1";, model_name: str = 
"llama3"
+) -> OllamaBackend:
+    return OllamaBackend(base_url, model_name, 
transport=httpx2.MockTransport(handler))
+
+
+class TestBuildNativeRoot:
+    @pytest.mark.parametrize(
+        "base_url",
+        [
+            "http://h:11434/v1";,
+            "http://h:11434/v1/";,
+            "http://h:11434";,
+            "http://h:11434/";,
+        ],
+    )
+    def test_returns_server_root(self, base_url):
+        assert build_ollama_native_root(base_url) == "http://h:11434";
+
+
+class TestOllamaBackendBaseUrl:
+    def test_none_base_url_falls_back_to_ollama_base_url_env(self, 
monkeypatch):
+        monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-ollama:11434/v1";)
+
+        assert OllamaBackend(base_url=None, model_name="llama3").root == 
"http://env-ollama:11434";
+
+    def test_explicit_base_url_wins_over_ollama_base_url_env(self, 
monkeypatch):
+        monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-ollama:11434/v1";)
+
+        assert OllamaBackend(base_url="http://h:11434";, 
model_name="llama3").root == "http://h:11434";
+
+    @pytest.mark.parametrize("base_url", [None, ""], ids=["base-url-none", 
"base-url-empty"])
+    @pytest.mark.parametrize("env_value", [None, ""], ids=["env-unset", 
"env-empty"])
+    def test_missing_base_url_and_env_raises_value_error(self, monkeypatch, 
base_url, env_value):
+        monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
+        if env_value is not None:
+            monkeypatch.setenv("OLLAMA_BASE_URL", env_value)
+
+        with pytest.raises(ValueError, match="OLLAMA_BASE_URL environment 
variable"):
+            OllamaBackend(base_url=base_url, model_name="llama3")
+
+
+class TestOllamaBackendLoad:
+    def 
test_posts_only_model_and_stream_to_generate_with_the_given_timeout(self):
+        requests: list[httpx2.Request] = []
+
+        def handler(request: httpx2.Request) -> httpx2.Response:
+            requests.append(request)
+            return httpx2.Response(200, json={"response": "", "done": True, 
"done_reason": "load"})
+
+        _build_backend(handler).load(timeout=12.0)
+
+        (request,) = requests
+        assert (request.method, request.url.path) == ("POST", "/api/generate")
+        assert json.loads(request.content) == {"model": "llama3", "stream": 
False}
+        assert request.extensions["timeout"]["read"] == 12.0
+
+    def test_http_error_names_model_and_server_error_field(self):
+        response = httpx2.Response(404, json={"error": "model 'llama3' not 
found"})
+        backend = _build_backend(lambda request: response)
+
+        with pytest.raises(httpx2.HTTPStatusError) as exc_info:
+            backend.load(timeout=5.0)
+
+        assert str(exc_info.value) == ("Ollama could not load model 'llama3' 
(404): model 'llama3' not found")
+        assert exc_info.value.response.status_code == 404
+        assert isinstance(exc_info.value.__cause__, httpx2.HTTPStatusError)
+
+    @pytest.mark.parametrize(
+        ("response", "expected_detail"),
+        [
+            pytest.param(
+                httpx2.Response(502, text="<html>Bad Gateway</html>"),
+                "<html>Bad Gateway</html>",
+                id="non-json-body",
+            ),
+            pytest.param(
+                httpx2.Response(500, json={"message": "boom"}), 
'{"message":"boom"}', id="json-without-error"
+            ),
+            pytest.param(httpx2.Response(500, text="x" * 500), "x" * 200, 
id="long-body-truncated"),
+            pytest.param(httpx2.Response(500), "Internal Server Error", 
id="empty-body"),
+            pytest.param(httpx2.Response(500, text="\n"), "Internal Server 
Error", id="whitespace-body"),
+        ],
+    )
+    def test_http_error_falls_back_to_body_text(self, response, 
expected_detail):
+        backend = _build_backend(lambda request: response)
+
+        with pytest.raises(httpx2.HTTPStatusError) as exc_info:
+            backend.load(timeout=5.0)
+
+        assert str(exc_info.value) == (
+            f"Ollama could not load model 'llama3' ({response.status_code}): 
{expected_detail}"
+        )
+
+    def test_timeout_is_raised_as_timeout_error_naming_model_and_seconds(self):
+        def handler(request: httpx2.Request) -> httpx2.Response:
+            raise httpx2.ReadTimeout("slow", request=request)
+
+        with pytest.raises(TimeoutError, match=r"'llama3'.*5\.0 seconds") as 
exc_info:
+            _build_backend(handler).load(timeout=5.0)
+
+        assert isinstance(exc_info.value.__cause__, httpx2.ReadTimeout)
+
+    def test_connect_timeout_says_server_was_not_reached(self):
+        def handler(request: httpx2.Request) -> httpx2.Response:
+            raise httpx2.ConnectTimeout("no route", request=request)
+
+        with pytest.raises(
+            TimeoutError, match=r"Could not reach Ollama at 
http://ollama:11434 within 5\.0"
+        ) as exc_info:
+            _build_backend(handler).load(timeout=5.0)
+
+        assert isinstance(exc_info.value.__cause__, httpx2.ConnectTimeout)
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py 
b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
index 47af1321456..4eea2c0983c 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
@@ -27,8 +27,9 @@ import time
 from concurrent.futures import ThreadPoolExecutor
 from contextvars import ContextVar
 from pathlib import Path
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import MagicMock, call, create_autospec, patch
 
+import httpx2
 import pytest
 from pydantic_ai import Agent
 from pydantic_ai.capabilities import AbstractCapability
@@ -44,6 +45,7 @@ from pydantic_ai.providers import infer_provider_class
 from airflow.models.connection import Connection
 from airflow.providers.common.ai.get_provider_info import get_provider_info
 from airflow.providers.common.ai.hooks.pydantic_ai import (
+    ModelReadinessBackend,
     PydanticAIAzureHook,
     PydanticAIBedrockHook,
     PydanticAIHook,
@@ -62,6 +64,10 @@ from airflow.providers_manager import HookInfo
 _GOOGLE_MODEL_PREFIX_RE = re.compile(r"google[\w-]*(?=:)")
 
 
+def build_backend(*, model_name: str, base_url: str | None) -> 
ModelReadinessBackend:
+    raise NotImplementedError
+
+
 def _assert_prefix_is_known_provider(prefix: str) -> None:
     """
     Assert pydantic-ai's provider registry recognizes ``prefix``.
@@ -1830,6 +1836,202 @@ class TestPydanticAIHookTestConnection:
         assert "unexpected keyword argument 'api_key'" in message
 
 
+class TestPydanticAIHookEnsureModelReady:
+    _HOOK_MODULE = "airflow.providers.common.ai.hooks.pydantic_ai"
+
+    @staticmethod
+    def _build_hook(
+        *,
+        model_id: str | None = "ollama:llama3",
+        host: str | None = "http://ollama:11434/v1";,
+        extra: dict | None = None,
+        **hook_kwargs,
+    ) -> tuple[PydanticAIHook, Connection]:
+        hook = PydanticAIHook(llm_conn_id="local_conn", model_id=model_id, 
**hook_kwargs)
+        conn = Connection(
+            conn_id="local_conn",
+            conn_type="pydanticai",
+            host=host,
+            extra=json.dumps(extra) if extra else None,
+        )
+        return hook, conn
+
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_builds_backend_from_host_and_unprefixed_model_name(self, 
mock_backend_cls):
+        hook, conn = self._build_hook()
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready()
+
+        mock_backend_cls.assert_called_once_with("http://ollama:11434/v1";, 
"llama3")
+
+    @pytest.mark.parametrize(
+        ("model_id", "expected_name"),
+        [
+            ("ollama:library/llama3", "library/llama3"),
+            ("ollama:llama3:70b", "llama3:70b"),
+            ("ollama:hf.co/org/model:q4", "hf.co/org/model:q4"),
+        ],
+    )
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_unprefixed_name_splits_on_the_first_colon_only(self, 
mock_backend_cls, model_id, expected_name):
+        hook, conn = self._build_hook(model_id=model_id)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready()
+
+        mock_backend_cls.assert_called_once_with("http://ollama:11434/v1";, 
expected_name)
+
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_load_is_called_once_with_the_given_timeout(self, 
mock_backend_cls):
+        hook, conn = self._build_hook()
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready(timeout=60.0)
+
+        
mock_backend_cls.return_value.load.assert_called_once_with(timeout=60.0)
+
+    @pytest.mark.parametrize("model_id", ["openai:gpt-5", 
"anthropic:claude-opus-4-6"])
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def 
test_non_ollama_prefix_raises_not_implemented_pointing_at_backend_factory(
+        self, mock_backend_cls, model_id
+    ):
+        hook, conn = self._build_hook(model_id=model_id)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(NotImplementedError, 
match=r"backend_factory=.*OllamaBackend"):
+                hook.ensure_model_ready()
+
+        mock_backend_cls.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("model_id", "expected_name"),
+        [
+            ("openai:llama3", "llama3"),
+            ("ollama:llama3:70b", "llama3:70b"),
+            ("openai:hf.co/org/model:q4", "hf.co/org/model:q4"),
+        ],
+    )
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_backend_factory_gets_unprefixed_model_name_and_connection_host(
+        self, mock_ollama_cls, model_id, expected_name
+    ):
+        stub = create_autospec(ModelReadinessBackend, instance=True)
+        factory = create_autospec(build_backend, return_value=stub)
+        hook, conn = self._build_hook(model_id=model_id)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready(timeout=60.0, backend_factory=factory)
+
+        factory.assert_called_once_with(model_name=expected_name, 
base_url="http://ollama:11434/v1";)
+        stub.load.assert_called_once_with(timeout=60.0)
+        mock_ollama_cls.assert_not_called()
+
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_backend_factory_skips_the_host_check(self, mock_ollama_cls):
+        stub = create_autospec(ModelReadinessBackend, instance=True)
+        factory = create_autospec(build_backend, return_value=stub)
+        hook, conn = self._build_hook(host=None)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready(backend_factory=factory)
+
+        factory.assert_called_once_with(model_name="llama3", base_url=None)
+        stub.load.assert_called_once()
+        mock_ollama_cls.assert_not_called()
+
+    @pytest.mark.parametrize(
+        "extra",
+        [
+            {"model": "ollama:llama3"},
+            {"model": "llama3", "model_provider": "ollama"},
+        ],
+        ids=["prefixed-model-in-extra", 
"bare-model-with-model-provider-in-extra"],
+    )
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_model_configured_only_on_the_connection(self, mock_backend_cls, 
extra):
+        hook, conn = self._build_hook(model_id=None, extra=extra)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready()
+
+        mock_backend_cls.assert_called_once_with("http://ollama:11434/v1";, 
"llama3")
+
+    @pytest.mark.parametrize("host", [None, ""])
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_missing_host_passes_none_base_url_to_ollama_backend(self, 
mock_backend_cls, monkeypatch, host):
+        monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-ollama:11434/v1";)
+        hook, conn = self._build_hook(host=host)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready()
+
+        mock_backend_cls.assert_called_once_with(None, "llama3")
+
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_connection_host_wins_over_ollama_base_url_env(self, 
mock_backend_cls, monkeypatch):
+        monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-ollama:11434/v1";)
+        hook, conn = self._build_hook()
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.ensure_model_ready()
+
+        mock_backend_cls.assert_called_once_with("http://ollama:11434/v1";, 
"llama3")
+
+    def test_missing_model_raises_value_error(self):
+        hook, conn = self._build_hook(model_id=None)
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="No model specified"):
+                hook.ensure_model_ready()
+
+    @pytest.mark.parametrize(
+        ("kwargs", "match"),
+        [
+            ({"timeout": 0}, r"got 0\."),
+            ({"timeout": -1.0}, r"got -1\.0\."),
+            ({"timeout": float("nan")}, r"got nan\."),
+            ({"timeout": float("inf")}, r"got inf\."),
+        ],
+    )
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_non_positive_arguments_raise_value_error(self, mock_backend_cls, 
kwargs, match):
+        hook, conn = self._build_hook()
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match=match):
+                hook.ensure_model_ready(**kwargs)
+
+        mock_backend_cls.assert_not_called()
+
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def 
test_only_the_primary_connection_is_read_when_fallbacks_are_configured(self, 
mock_backend_cls):
+        hook, conn = self._build_hook(fallback_conn_ids=["fallback_conn"])
+
+        with patch.object(hook, "get_connection", autospec=True, 
return_value=conn) as mock_get_connection:
+            hook.ensure_model_ready()
+
+        mock_get_connection.assert_called_once_with("local_conn")
+
+    @pytest.mark.parametrize(
+        "error",
+        [
+            httpx2.ConnectError("refused"),
+            httpx2.HTTPStatusError("busy", request=MagicMock(), 
response=httpx2.Response(503)),
+            TimeoutError("slow"),
+        ],
+        ids=["connect-error", "http-503", "request-timeout"],
+    )
+    @patch(f"{_HOOK_MODULE}.OllamaBackend", autospec=True)
+    def test_load_errors_are_raised_as_is(self, mock_backend_cls, error):
+        mock_backend_cls.return_value.load.side_effect = error
+        hook, conn = self._build_hook()
+
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(type(error)):
+                hook.ensure_model_ready()
+
+
 # ---------------------------------------------------------------------------
 # Subclass hook tests
 # ---------------------------------------------------------------------------
diff --git a/uv.lock b/uv.lock
index 76132824ca5..9f28d88a752 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4642,6 +4642,7 @@ dependencies = [
     { name = "apache-airflow" },
     { name = "apache-airflow-providers-common-compat" },
     { name = "apache-airflow-providers-standard" },
+    { name = "httpx2" },
     { name = "pydantic-ai-slim" },
     { name = "structlog" },
 ]
@@ -4777,6 +4778,7 @@ requires-dist = [
     { name = "fastavro", marker = "python_full_version >= '3.14' and extra == 
'avro'", specifier = ">=1.12.1" },
     { name = "fastavro", marker = "python_full_version < '3.14' and extra == 
'avro'", specifier = ">=1.10.0" },
     { name = "grpcio", marker = "extra == 'openshell'", specifier = ">=1.78.0" 
},
+    { name = "httpx2", specifier = ">=2.7" },
     { name = "islo", marker = "extra == 'islo'", specifier = ">=0.3.19" },
     { name = "langchain", marker = "extra == 'langchain'", specifier = 
">=1.0.0" },
     { name = "llama-index-core", marker = "extra == 'llamaindex'", specifier = 
">=0.14.5" },

Reply via email to