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


##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -562,6 +586,92 @@ def _provider_factory(pname: str) -> Any:
 
         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 _resolve_readiness_backend(self, model_name: str) -> 
ModelReadinessBackend:
+        """Build the built-in readiness backend for *model_name*, which only 
exists for Ollama."""
+        prefix, _, ollama_model = model_name.partition(":")
+        if 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= an object with is_ready() and load()."
+            )
+        conn, _ = self._get_conn_and_extra()
+        if not conn.host:

Review Comment:
   `get_conn()` works for an Ollama connection with no host, since 
pydantic-ai's `OllamaProvider` falls back to `OLLAMA_BASE_URL`, but this raises 
`ValueError` for that same connection. Should it use `conn.host or 
os.getenv("OLLAMA_BASE_URL")` and raise only when both are empty?



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -45,6 +51,27 @@
 P = ParamSpec("P")
 R = TypeVar("R")
 
+
+class ModelReadinessBackend(Protocol):

Review Comment:
   The hook resolves the model name and connection, but the backend never gets 
them, so a custom backend has to carry its own URL and model name. That also 
hits the `openai:<model>` against Ollama's `/v1` setup that 
`self_hosted_models.rst` calls valid: it gets `NotImplementedError`, and the 
only way around it is building `OllamaBackend` by hand, which isn't documented. 
Would passing `model_name` and `base_url` to the backend (or taking a factory) 
work better?



##########
providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py:
##########
@@ -1830,6 +1832,305 @@ def test_reports_provider_class_build_failure(
         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";,
+        **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)
+        return hook, conn
+
+    @staticmethod
+    def _install_fake_clock(module: str):

Review Comment:
   Now that the polling is gone, the `sleep` wiring here and 
`mock_time.sleep.assert_not_called()` below check a call the method can't make. 
Could these two helpers become one fixture that only patches `time.monotonic`?



##########
providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py:
##########
@@ -1830,6 +1832,305 @@ def test_reports_provider_class_build_failure(
         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",

Review Comment:
   Every test sets `model_id`, so the setup the docs show (model in the 
connection extra, `PydanticAIHook(llm_conn_id=...)` alone) isn't covered, and 
neither is a bare name with the `model_provider` extra. Could one case use 
`model_id=None` with the model in `extra` and assert what `OllamaBackend` is 
built with?



##########
providers/common/ai/docs/self_hosted_models.rst:
##########
@@ -257,6 +257,59 @@ 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.
 
+Waiting for an Ollama model to load
+-------------------------------------
+
+Ollama loads a model into memory on the first request, which can take long
+enough to time out that request. To load the model ahead of time, call
+:meth:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook.ensure_model_ready`
+before the task builds its agent. It asks Ollama which models are loaded,
+sends a load request if this connection's model is missing, waits for the load
+to finish, then checks once that Ollama lists the model:
+
+.. 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:

Review Comment:
   How would `@task.llm` or `AgentOperator` users use this? They build their 
own hook, so I guess it'd be an upstream task. But the load doesn't send 
`keep_alive`, so Ollama drops the model again after 5 minutes (its default) if 
the LLM task sits in the queue longer. Should the docs mention that?



##########
providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py:
##########
@@ -562,6 +586,92 @@ def _provider_factory(pname: str) -> Any:
 
         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 _resolve_readiness_backend(self, model_name: str) -> 
ModelReadinessBackend:
+        """Build the built-in readiness backend for *model_name*, which only 
exists for Ollama."""
+        prefix, _, ollama_model = model_name.partition(":")
+        if 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= an object with is_ready() and load()."
+            )
+        conn, _ = self._get_conn_and_extra()
+        if not conn.host:
+            raise ValueError(f"Connection '{self.llm_conn_id}' needs a host to 
check model readiness.")
+        return OllamaBackend(conn.host, ollama_model)
+
+    def ensure_model_ready(
+        self, *, timeout: float = 300.0, backend: ModelReadinessBackend | None 
= None
+    ) -> None:
+        """
+        Block until this connection's local model is loaded, asking the server 
to load it if needed.
+
+        Asks the server whether the model is loaded; if not, sends a load 
request that returns once
+        the model is loaded, then checks once more that the server lists it. 
Any error from the
+        server is raised as is.
+
+        Ollama is built in; for any other server pass *backend*. Only this 
hook's own connection is
+        checked -- connections in ``fallback_conn_ids`` are not, so a fallback 
model is never loaded
+        ahead of need.
+
+        The built-in Ollama backend does not send the connection password, so 
a server behind
+        authentication is not supported.
+
+        Blocks and holds the worker slot for the whole wait.
+
+        :param timeout: Seconds to wait in total for the model to become ready.
+        :param backend: Object with is_ready() and load() (see 
ModelReadinessBackend) to check and
+            load the model; when given, only the model prefix and connection 
host checks are skipped.
+            The model name is still resolved from the hook argument or the 
connection, and a
+            missing model still raises ``ValueError``.
+        :raises ValueError: If *timeout* is not a positive finite number, no 
model is configured,
+            or *backend* is not given and the connection has no host.
+        :raises NotImplementedError: If *backend* is not given and the model's 
provider prefix has
+            no built-in backend (only ``ollama`` has one).
+        :raises TimeoutError: If the time runs out before the model is 
confirmed ready, or a
+            request times out.
+        :raises RuntimeError: If the load request finishes but the server does 
not list the model
+            as loaded.
+        :raises httpx2.TransportError: If a request cannot reach the server.
+        :raises httpx2.HTTPStatusError: If the server answers with an error 
status.
+        """
+        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)
+        if backend is None:
+            backend = self._resolve_readiness_backend(model_name)
+
+        started = time.monotonic()
+        deadline = started + timeout
+        if backend.is_ready(timeout=min(timeout, DEFAULT_REQUEST_TIMEOUT)):

Review Comment:
   `/api/ps` doesn't look like a safe readiness signal while a load is in 
flight. On Ollama v0.30.0 and earlier it lists a model as soon as the load 
starts (the runner goes into `s.sched.loaded` before `WaitUntilRunning`), so a 
mapped sibling task can see `True` here mid-load. On v0.40.13 `loadedModels()` 
waits on each runner's `refMu`, which the scheduler holds until the load 
finishes, so this call blocks instead. If any model on the server takes over 
30s to load, this raises `TimeoutError`.
   
   An empty-prompt `/api/generate` already waits for the load, and returns 
right away if the model is loaded. Could we send just that with the remaining 
budget and drop both `/api/ps` checks? That'd also get rid of the name compare, 
which won't match `/api/ps` for names like `library/llama3`.



##########
providers/common/ai/tests/unit/common/ai/hooks/test_ollama.py:
##########
@@ -0,0 +1,156 @@
+# 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,
+    normalize_ollama_model_name,
+)
+
+
+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 TestNormalizeModelName:
+    @pytest.mark.parametrize(
+        ("model_name", "expected"),
+        [
+            ("llama3", "llama3:latest"),
+            ("llama3:8b", "llama3:8b"),
+            ("hf.co/org/model:q4", "hf.co/org/model:q4"),
+            ("localhost:5000/library/llama3", 
"localhost:5000/library/llama3:latest"),
+        ],
+    )
+    def test_adds_latest_only_when_tag_is_missing(self, model_name, expected):
+        assert normalize_ollama_model_name(model_name) == expected
+
+
+class TestOllamaBackendIsReady:
+    @pytest.mark.parametrize(
+        ("model_name", "loaded", "expected"),
+        [
+            ("llama3", ["llama3:latest"], True),
+            ("llama3:latest", ["llama3:latest"], True),
+            ("llama3:8b", ["llama3:8b", "other:latest"], True),
+            ("llama3:8b", ["llama3:70b"], False),
+            ("llama3", ["llama3:8b"], False),
+            ("llama3", [], False),
+        ],
+    )
+    def test_matches_model_name_against_loaded_models(self, model_name, 
loaded, expected):
+        requests: list[httpx2.Request] = []
+
+        def handler(request: httpx2.Request) -> httpx2.Response:
+            requests.append(request)
+            return httpx2.Response(200, json={"models": [{"name": name} for 
name in loaded]})
+
+        assert _build_backend(handler, model_name=model_name).is_ready() is 
expected
+        assert [(r.method, r.url.path) for r in requests] == [("GET", 
"/api/ps")]
+
+    def test_http_error_propagates(self):
+        backend = _build_backend(lambda request: httpx2.Response(503))
+
+        with pytest.raises(httpx2.HTTPStatusError):
+            backend.is_ready()
+
+    def test_connection_error_propagates(self):
+        def handler(request: httpx2.Request) -> httpx2.Response:
+            raise httpx2.ConnectError("refused", request=request)
+
+        with pytest.raises(httpx2.ConnectError):
+            _build_backend(handler).is_ready()
+
+    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).is_ready(timeout=5.0)
+
+        assert isinstance(exc_info.value.__cause__, httpx2.ReadTimeout)
+
+    def test_non_json_body_propagates_decode_error(self):
+        backend = _build_backend(lambda request: httpx2.Response(200, 
content=b"<html>"))
+
+        with pytest.raises(json.JSONDecodeError):
+            backend.is_ready()
+
+
+class TestOllamaBackendLoad:
+    def test_posts_model_to_generate_without_keep_alive(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")
+        body = json.loads(request.content)
+        assert body["model"] == "llama3"

Review Comment:
   Nothing here checks the timeout the client actually gets: the timeout tests 
raise `ReadTimeout` from the handler, and the seconds in the message come from 
the argument. Could this assert that `request.extensions["timeout"]["read"]` 
equals the timeout passed to `load()`, and `body == {"model": "llama3", 
"stream": False}` while at it? `load()` quietly falling back to 30s is the 
failure this feature exists to avoid.



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