diff --git a/libs/partners/ollama/langchain_ollama/_utils.py b/libs/partners/ollama/langchain_ollama/_utils.py new file mode 100644 index 00000000000..076a2cc164a --- /dev/null +++ b/libs/partners/ollama/langchain_ollama/_utils.py @@ -0,0 +1,37 @@ +"""Utility functions for validating Ollama models.""" + +from httpx import ConnectError +from ollama import Client, ResponseError + + +def validate_model(client: Client, model_name: str) -> None: + """Validate that a model exists in the Ollama instance. + + Args: + client: The Ollama client. + model_name: The name of the model to validate. + + Raises: + ValueError: If the model is not found or if there's a connection issue. + """ + try: + response = client.list() + model_names: list[str] = [model["name"] for model in response["models"]] + if not any( + model_name == m or m.startswith(f"{model_name}:") for m in model_names + ): + raise ValueError( + f"Model `{model_name}` not found in Ollama. Please pull the " + f"model (using `ollama pull {model_name}`) or specify a valid " + f"model name. Available local models: {', '.join(model_names)}" + ) + except ConnectError as e: + raise ValueError( + "Connection to Ollama failed. Please make sure Ollama is running " + f"and accessible at {client._client.base_url}. " + ) from e + except ResponseError as e: + raise ValueError( + "Received an error from the Ollama API. " + "Please check your Ollama server logs." + ) from e diff --git a/libs/partners/ollama/langchain_ollama/chat_models.py b/libs/partners/ollama/langchain_ollama/chat_models.py index 9c2dd20e012..3341cdde58e 100644 --- a/libs/partners/ollama/langchain_ollama/chat_models.py +++ b/libs/partners/ollama/langchain_ollama/chat_models.py @@ -55,6 +55,8 @@ from pydantic.json_schema import JsonSchemaValue from pydantic.v1 import BaseModel as BaseModelV1 from typing_extensions import Self, is_typeddict +from ._utils import validate_model + DEFAULT_THINK_TOKEN_START: Final[str] = "" DEFAULT_THINK_TOKEN_END: Final[str] = "" @@ -350,6 +352,9 @@ class ChatOllama(BaseChatModel): model: str """Model name to use.""" + validate_model_on_init: bool = False + """Whether to validate the model exists in Ollama locally on initialization.""" + extract_reasoning: Optional[Union[bool, tuple[str, str]]] = False """Whether to extract the reasoning tokens in think blocks. Extracts `chunk.content` to `chunk.additional_kwargs.reasoning_content`. @@ -529,6 +534,8 @@ class ChatOllama(BaseChatModel): self._client = Client(host=self.base_url, **sync_client_kwargs) self._async_client = AsyncClient(host=self.base_url, **async_client_kwargs) + if self.validate_model_on_init: + validate_model(self._client, self.model) return self def _convert_messages_to_ollama_messages( @@ -1226,7 +1233,7 @@ class ChatOllama(BaseChatModel): "schema": schema, }, ) - output_parser = PydanticOutputParser(pydantic_object=schema) + output_parser = PydanticOutputParser(pydantic_object=schema) # type: ignore[arg-type] else: if is_typeddict(schema): response_format = convert_to_json_schema(schema) diff --git a/libs/partners/ollama/langchain_ollama/embeddings.py b/libs/partners/ollama/langchain_ollama/embeddings.py index 8b2802858e5..ea15b71e87c 100644 --- a/libs/partners/ollama/langchain_ollama/embeddings.py +++ b/libs/partners/ollama/langchain_ollama/embeddings.py @@ -12,6 +12,8 @@ from pydantic import ( ) from typing_extensions import Self +from ._utils import validate_model + class OllamaEmbeddings(BaseModel, Embeddings): """Ollama embedding model integration. @@ -123,6 +125,9 @@ class OllamaEmbeddings(BaseModel, Embeddings): model: str """Model name to use.""" + validate_model_on_init: bool = False + """Whether to validate the model exists in ollama locally on initialization.""" + base_url: Optional[str] = None """Base url the model is hosted under.""" @@ -259,6 +264,8 @@ class OllamaEmbeddings(BaseModel, Embeddings): self._client = Client(host=self.base_url, **sync_client_kwargs) self._async_client = AsyncClient(host=self.base_url, **async_client_kwargs) + if self.validate_model_on_init: + validate_model(self._client, self.model) return self def embed_documents(self, texts: list[str]) -> list[list[float]]: diff --git a/libs/partners/ollama/langchain_ollama/llms.py b/libs/partners/ollama/langchain_ollama/llms.py index 83330ec5e3d..2c73c28ef57 100644 --- a/libs/partners/ollama/langchain_ollama/llms.py +++ b/libs/partners/ollama/langchain_ollama/llms.py @@ -18,6 +18,8 @@ from ollama import AsyncClient, Client, Options from pydantic import PrivateAttr, model_validator from typing_extensions import Self +from ._utils import validate_model + class OllamaLLM(BaseLLM): """OllamaLLM large language models. @@ -34,6 +36,9 @@ class OllamaLLM(BaseLLM): model: str """Model name to use.""" + validate_model_on_init: bool = False + """Whether to validate the model exists in ollama locally on initialization.""" + mirostat: Optional[int] = None """Enable Mirostat sampling for controlling perplexity. (default: 0, 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0)""" @@ -215,6 +220,8 @@ class OllamaLLM(BaseLLM): self._client = Client(host=self.base_url, **sync_client_kwargs) self._async_client = AsyncClient(host=self.base_url, **async_client_kwargs) + if self.validate_model_on_init: + validate_model(self._client, self.model) return self async def _acreate_generate_stream( diff --git a/libs/partners/ollama/tests/integration_tests/chat_models/cassettes/test_chat_models_standard/TestChatOllama.test_stream_time.yaml b/libs/partners/ollama/tests/integration_tests/chat_models/cassettes/test_chat_models_standard/TestChatOllama.test_stream_time.yaml new file mode 100644 index 00000000000..6c5a87b6e75 --- /dev/null +++ b/libs/partners/ollama/tests/integration_tests/chat_models/cassettes/test_chat_models_standard/TestChatOllama.test_stream_time.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-type: + - application/json + host: + - 127.0.0.1:11434 + user-agent: + - ollama-python/0.5.1 (arm64 darwin) Python/3.10.16 + method: GET + uri: http://127.0.0.1:11434/api/tags + response: + body: + string: '{"models":[{"name":"deepseek-r1:8b","model":"deepseek-r1:8b","modified_at":"2025-06-28T01:12:36.619720716-04:00","size":5225376047,"digest":"6995872bfe4c521a67b32da386cd21d5c6e819b6e0d62f79f64ec83be99f5763","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"8.2B","quantization_level":"Q4_K_M"}},{"name":"deepseek-r1:1.5b","model":"deepseek-r1:1.5b","modified_at":"2025-06-28T01:12:14.502483098-04:00","size":1117322768,"digest":"e0979632db5a88d1a53884cb2a941772d10ff5d055aabaa6801c4e36f3a6c2d7","details":{"parent_model":"","format":"gguf","family":"qwen2","families":["qwen2"],"parameter_size":"1.8B","quantization_level":"Q4_K_M"}},{"name":"granite3.2:8b","model":"granite3.2:8b","modified_at":"2025-06-25T14:56:40.551100022-04:00","size":4942877287,"digest":"9bcb3335083f7eecc742d3916da858f66e6ba8dc450a233270f37ba2ecec6c79","details":{"parent_model":"","format":"gguf","family":"granite","families":["granite"],"parameter_size":"8.2B","quantization_level":"Q4_K_M"}},{"name":"bakllava:latest","model":"bakllava:latest","modified_at":"2025-06-25T14:53:32.313094104-04:00","size":4733351307,"digest":"3dd68bd4447cba20e20deba918749e7f58ff689a8ba4a90c9ff9dc9118037486","details":{"parent_model":"","format":"gguf","family":"llama","families":["llama","clip"],"parameter_size":"7B","quantization_level":"Q4_0"}},{"name":"qwen3:14b","model":"qwen3:14b","modified_at":"2025-06-24T15:23:01.652116724-04:00","size":9276198565,"digest":"bdbd181c33f2ed1b31c972991882db3cf4d192569092138a7d29e973cd9debe8","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"14.8B","quantization_level":"Q4_K_M"}},{"name":"deepseek-r1:latest","model":"deepseek-r1:latest","modified_at":"2025-06-24T14:38:30.266396429-04:00","size":5225376047,"digest":"6995872bfe4c521a67b32da386cd21d5c6e819b6e0d62f79f64ec83be99f5763","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"8.2B","quantization_level":"Q4_K_M"}},{"name":"gemma3:latest","model":"gemma3:latest","modified_at":"2025-06-24T14:00:47.814400435-04:00","size":3338801804,"digest":"a2af6cc3eb7fa8be8504abaf9b04e88f17a119ec3f04a3addf55f92841195f5a","details":{"parent_model":"","format":"gguf","family":"gemma3","families":["gemma3"],"parameter_size":"4.3B","quantization_level":"Q4_K_M"}},{"name":"qwen3:8b","model":"qwen3:8b","modified_at":"2025-06-24T13:41:32.032308856-04:00","size":5225388164,"digest":"500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"8.2B","quantization_level":"Q4_K_M"}},{"name":"llama4:latest","model":"llama4:latest","modified_at":"2025-06-24T11:56:25.773177793-04:00","size":67436862523,"digest":"bf31604e25c25d964e250bcf28a82bfbdbe88af5f236257fabb27629bb24c7f3","details":{"parent_model":"","format":"gguf","family":"llama4","families":["llama4"],"parameter_size":"108.6B","quantization_level":"Q4_K_M"}},{"name":"granite3.2-vision:latest","model":"granite3.2-vision:latest","modified_at":"2025-06-24T11:19:40.600433668-04:00","size":2437852465,"digest":"3be41a661804ad72cd08269816c5a145f1df6479ad07e2b3a7e29dba575d2669","details":{"parent_model":"","format":"gguf","family":"granite","families":["granite","clip"],"parameter_size":"2.5B","quantization_level":"Q4_K_M"}},{"name":"mistral-small3.2:latest","model":"mistral-small3.2:latest","modified_at":"2025-06-24T11:16:17.938210984-04:00","size":15177384862,"digest":"5a408ab55df5c1b5cf46533c368813b30bf9e4d8fc39263bf2a3338cfa3b895b","details":{"parent_model":"","format":"gguf","family":"mistral3","families":["mistral3"],"parameter_size":"24.0B","quantization_level":"Q4_K_M"}},{"name":"mistral-small3.1:latest","model":"mistral-small3.1:latest","modified_at":"2025-06-24T11:07:35.44539952-04:00","size":15486899116,"digest":"b9aaf0c2586a8ed8105feab808c0f034bd4d346203822f048e2366165a13f4ea","details":{"parent_model":"","format":"gguf","family":"mistral3","families":["mistral3"],"parameter_size":"24.0B","quantization_level":"Q4_K_M"}},{"name":"gemma3:4b","model":"gemma3:4b","modified_at":"2025-06-23T17:23:28.663213497-04:00","size":3338801804,"digest":"a2af6cc3eb7fa8be8504abaf9b04e88f17a119ec3f04a3addf55f92841195f5a","details":{"parent_model":"","format":"gguf","family":"gemma3","families":["gemma3"],"parameter_size":"4.3B","quantization_level":"Q4_K_M"}},{"name":"llama3:latest","model":"llama3:latest","modified_at":"2025-06-23T17:20:14.737102442-04:00","size":4661224676,"digest":"365c0bd3c000a25d28ddbf732fe1c6add414de7275464c4e4d1c3b5fcb5d8ad1","details":{"parent_model":"","format":"gguf","family":"llama","families":["llama"],"parameter_size":"8.0B","quantization_level":"Q4_0"}},{"name":"llama3.1:latest","model":"llama3.1:latest","modified_at":"2025-06-23T17:15:26.037326254-04:00","size":4920753328,"digest":"46e0c10c039e019119339687c3c1757cc81b9da49709a3b3924863ba87ca666e","details":{"parent_model":"","format":"gguf","family":"llama","families":["llama"],"parameter_size":"8.0B","quantization_level":"Q4_K_M"}},{"name":"llama3.2:latest","model":"llama3.2:latest","modified_at":"2025-06-23T17:01:52.264371207-04:00","size":2019393189,"digest":"a80c4f17acd55265feec403c7aef86be0c25983ab279d83f3bcd3abbcb5b8b72","details":{"parent_model":"","format":"gguf","family":"llama","families":["llama"],"parameter_size":"3.2B","quantization_level":"Q4_K_M"}}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 28 Jun 2025 21:08:54 GMT + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +version: 1 diff --git a/libs/partners/ollama/tests/integration_tests/chat_models/test_chat_models_standard.py b/libs/partners/ollama/tests/integration_tests/chat_models/test_chat_models_standard.py index 0fda39fef0d..c3216b8a37a 100644 --- a/libs/partners/ollama/tests/integration_tests/chat_models/test_chat_models_standard.py +++ b/libs/partners/ollama/tests/integration_tests/chat_models/test_chat_models_standard.py @@ -1,8 +1,13 @@ """Test chat model integration using standard integration tests.""" +from unittest.mock import MagicMock, patch + import pytest +from httpx import ConnectError from langchain_core.language_models import BaseChatModel from langchain_tests.integration_tests import ChatModelIntegrationTests +from ollama import ResponseError +from pydantic import ValidationError from langchain_ollama.chat_models import ChatOllama @@ -47,3 +52,29 @@ class TestChatOllama(ChatModelIntegrationTests): ) async def test_tool_calling_async(self, model: BaseChatModel) -> None: await super().test_tool_calling_async(model) + + @patch("langchain_ollama.chat_models.Client.list") + def test_init_model_not_found(self, mock_list: MagicMock) -> None: + """Test that a ValueError is raised when the model is not found.""" + mock_list.side_effect = ValueError("Test model not found") + with pytest.raises(ValueError) as excinfo: + ChatOllama(model="non-existent-model", validate_model_on_init=True) + assert "Test model not found" in str(excinfo.value) + + @patch("langchain_ollama.chat_models.Client.list") + def test_init_connection_error(self, mock_list: MagicMock) -> None: + """Test that a ValidationError is raised on connect failure during init.""" + mock_list.side_effect = ConnectError("Test connection error") + + with pytest.raises(ValidationError) as excinfo: + ChatOllama(model="any-model", validate_model_on_init=True) + assert "not found in Ollama" in str(excinfo.value) + + @patch("langchain_ollama.chat_models.Client.list") + def test_init_response_error(self, mock_list: MagicMock) -> None: + """Test that a ResponseError is raised.""" + mock_list.side_effect = ResponseError("Test response error") + + with pytest.raises(ValidationError) as excinfo: + ChatOllama(model="any-model", validate_model_on_init=True) + assert "Received an error from the Ollama API" in str(excinfo.value) diff --git a/libs/partners/ollama/tests/unit_tests/test_chat_models.py b/libs/partners/ollama/tests/unit_tests/test_chat_models.py index ff552fef5d0..638e5844711 100644 --- a/libs/partners/ollama/tests/unit_tests/test_chat_models.py +++ b/libs/partners/ollama/tests/unit_tests/test_chat_models.py @@ -4,6 +4,7 @@ import json from collections.abc import Generator from contextlib import contextmanager from typing import Any +from unittest.mock import patch import pytest from httpx import Client, Request, Response @@ -12,6 +13,8 @@ from langchain_tests.unit_tests import ChatModelUnitTests from langchain_ollama.chat_models import ChatOllama, _parse_arguments_from_tool_call +MODEL_NAME = "llama3.1" + class TestChatOllama(ChatModelUnitTests): @property @@ -49,7 +52,7 @@ def test_arbitrary_roles_accepted_in_chatmessages( llm = ChatOllama( base_url="http://whocares:11434", - model="granite3.2", + model=MODEL_NAME, verbose=True, format=None, ) @@ -64,3 +67,20 @@ def test_arbitrary_roles_accepted_in_chatmessages( ] llm.invoke(messages) + + +@patch("langchain_ollama.chat_models.validate_model") +def test_validate_model_on_init(mock_validate_model: Any) -> None: + """Test that the model is validated on initialization when requested.""" + # Test that validate_model is called when validate_model_on_init=True + ChatOllama(model=MODEL_NAME, validate_model_on_init=True) + mock_validate_model.assert_called_once() + mock_validate_model.reset_mock() + + # Test that validate_model is NOT called when validate_model_on_init=False + ChatOllama(model=MODEL_NAME, validate_model_on_init=False) + mock_validate_model.assert_not_called() + + # Test that validate_model is NOT called by default + ChatOllama(model=MODEL_NAME) + mock_validate_model.assert_not_called() diff --git a/libs/partners/ollama/tests/unit_tests/test_embeddings.py b/libs/partners/ollama/tests/unit_tests/test_embeddings.py index 82b5679c35a..cbca95a994b 100644 --- a/libs/partners/ollama/tests/unit_tests/test_embeddings.py +++ b/libs/partners/ollama/tests/unit_tests/test_embeddings.py @@ -1,8 +1,30 @@ """Test embedding model integration.""" +from typing import Any +from unittest.mock import patch + from langchain_ollama.embeddings import OllamaEmbeddings +MODEL_NAME = "llama3.1" + def test_initialization() -> None: """Test embedding model initialization.""" OllamaEmbeddings(model="llama3", keep_alive=1) + + +@patch("langchain_ollama.embeddings.validate_model") +def test_validate_model_on_init(mock_validate_model: Any) -> None: + """Test that the model is validated on initialization when requested.""" + # Test that validate_model is called when validate_model_on_init=True + OllamaEmbeddings(model=MODEL_NAME, validate_model_on_init=True) + mock_validate_model.assert_called_once() + mock_validate_model.reset_mock() + + # Test that validate_model is NOT called when validate_model_on_init=False + OllamaEmbeddings(model=MODEL_NAME, validate_model_on_init=False) + mock_validate_model.assert_not_called() + + # Test that validate_model is NOT called by default + OllamaEmbeddings(model=MODEL_NAME) + mock_validate_model.assert_not_called() diff --git a/libs/partners/ollama/tests/unit_tests/test_llms.py b/libs/partners/ollama/tests/unit_tests/test_llms.py index 74cbc1448f5..7f68777c96c 100644 --- a/libs/partners/ollama/tests/unit_tests/test_llms.py +++ b/libs/partners/ollama/tests/unit_tests/test_llms.py @@ -1,7 +1,12 @@ """Test Ollama Chat API wrapper.""" +from typing import Any +from unittest.mock import patch + from langchain_ollama import OllamaLLM +MODEL_NAME = "llama3.1" + def test_initialization() -> None: """Test integration initialization.""" @@ -26,3 +31,20 @@ def test_model_params() -> None: "ls_model_name": "llama3", "ls_max_tokens": 3, } + + +@patch("langchain_ollama.llms.validate_model") +def test_validate_model_on_init(mock_validate_model: Any) -> None: + """Test that the model is validated on initialization when requested.""" + # Test that validate_model is called when validate_model_on_init=True + OllamaLLM(model=MODEL_NAME, validate_model_on_init=True) + mock_validate_model.assert_called_once() + mock_validate_model.reset_mock() + + # Test that validate_model is NOT called when validate_model_on_init=False + OllamaLLM(model=MODEL_NAME, validate_model_on_init=False) + mock_validate_model.assert_not_called() + + # Test that validate_model is NOT called by default + OllamaLLM(model=MODEL_NAME) + mock_validate_model.assert_not_called() diff --git a/libs/partners/ollama/uv.lock b/libs/partners/ollama/uv.lock index d2b7a04d873..ff4d4009e0f 100644 --- a/libs/partners/ollama/uv.lock +++ b/libs/partners/ollama/uv.lock @@ -363,7 +363,7 @@ typing = [ [[package]] name = "langchain-ollama" -version = "0.3.4" +version = "0.3.3" source = { editable = "." } dependencies = [ { name = "langchain-core" },