From 6260a02f15f33e84c8d3f7058081e9abf8578679 Mon Sep 17 00:00:00 2001 From: Timelovers <46080686+Timelovers@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:14:55 +0800 Subject: [PATCH] feat(component): add Ollama LLM and Embedding providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OllamaProvider and OllamaEmbeddingProvider — thin subclasses of OpenAIProvider / OpenAIEmbeddingProvider with Ollama-specific defaults (base_url=http://localhost:11434/v1, api_key='ollama', dim=768). Ollama's API is OpenAI-compatible, so these providers extend the existing implementations with no new logic — just sensible local-first defaults. Users can now do: from everos.component.llm import OllamaProvider llm = OllamaProvider(model='llama3.1') instead of remembering base_url and supplying a dummy key. Also add 8 unit tests (defaults, custom params, inheritance, imports). Co-authored-by: Timelovers <46080686+Timelovers@users.noreply.github.com> --- src/everos/component/embedding/__init__.py | 4 + .../component/embedding/ollama_provider.py | 66 +++++++++++ src/everos/component/llm/__init__.py | 4 + src/everos/component/llm/ollama_provider.py | 61 ++++++++++ .../test_component/test_ollama_providers.py | 106 ++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 src/everos/component/embedding/ollama_provider.py create mode 100644 src/everos/component/llm/ollama_provider.py create mode 100644 tests/unit/test_component/test_ollama_providers.py diff --git a/src/everos/component/embedding/__init__.py b/src/everos/component/embedding/__init__.py index f1ec9878b..b55455208 100644 --- a/src/everos/component/embedding/__init__.py +++ b/src/everos/component/embedding/__init__.py @@ -8,6 +8,8 @@ - :class:`EmbeddingError` — backward-compat alias for ``EmbeddingServiceError``. - :class:`OpenAIEmbeddingProvider` — concrete provider for any OpenAI-protocol embeddings endpoint (DeepInfra, vLLM, OpenAI, …). +- :class:`OllamaEmbeddingProvider` — local Ollama embedding provider + with sensible defaults (``http://localhost:11434/v1``, dim=768). - :func:`build_embedding_provider` — settings-driven factory. External usage:: @@ -22,6 +24,7 @@ from .accessor import EmbeddingNotConfiguredError as EmbeddingNotConfiguredError from .accessor import get_embedder as get_embedder from .factory import build_embedding_provider as build_embedding_provider +from .ollama_provider import OllamaEmbeddingProvider as OllamaEmbeddingProvider from .openai_provider import OpenAIEmbeddingProvider as OpenAIEmbeddingProvider from .protocol import EmbeddingError as EmbeddingError from .protocol import EmbeddingProvider as EmbeddingProvider @@ -31,6 +34,7 @@ "EmbeddingNotConfiguredError", "EmbeddingProvider", "EmbeddingServiceError", + "OllamaEmbeddingProvider", "OpenAIEmbeddingProvider", "build_embedding_provider", "get_embedder", diff --git a/src/everos/component/embedding/ollama_provider.py b/src/everos/component/embedding/ollama_provider.py new file mode 100644 index 000000000..497756c3d --- /dev/null +++ b/src/everos/component/embedding/ollama_provider.py @@ -0,0 +1,66 @@ +"""Ollama embedding provider. + +Ollama exposes an OpenAI-compatible ``/v1/embeddings`` endpoint at +``http://localhost:11434/v1``. This provider applies Ollama-specific +defaults so callers get a working configuration out of the box — no +need to remember the base URL or supply a dummy API key. + +Usage:: + from everos.component.embedding.ollama_provider import OllamaEmbeddingProvider + + provider = OllamaEmbeddingProvider( + model="nomic-embed-text", + dim=768, + ) + vec = await provider.embed("hello world") +""" + +from __future__ import annotations + +from .openai_provider import OpenAIEmbeddingProvider + + +_OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1" +_OLLAMA_PLACEHOLDER_KEY = "ollama" + + +class OllamaEmbeddingProvider(OpenAIEmbeddingProvider): + """Ollama embedding provider — OpenAI-compatible with local defaults. + + Same semantics as :class:`OpenAIEmbeddingProvider` except + ``api_key`` and ``base_url`` fall back to Ollama's conventional + values when omitted. + + Args: + model: Ollama model id (e.g. ``"nomic-embed-text"``). + api_key: API key; defaults to ``"ollama"``. + base_url: Endpoint; defaults to ``http://localhost:11434/v1``. + dim: Target vector dimension; defaults to 768 (Ollama's typical). + timeout: Per-request timeout, seconds. + max_retries: Retry budget. + batch_size: Embeddings per ``/embeddings`` call. + max_concurrent: Cap on in-flight chunked requests. + """ + + def __init__( + self, + *, + model: str, + api_key: str = _OLLAMA_PLACEHOLDER_KEY, + base_url: str = _OLLAMA_DEFAULT_BASE_URL, + dim: int = 768, + timeout: float = 30.0, + max_retries: int = 3, + batch_size: int = 10, + max_concurrent: int = 5, + ) -> None: + super().__init__( + model=model, + api_key=api_key, + base_url=base_url, + dim=dim, + timeout=timeout, + max_retries=max_retries, + batch_size=batch_size, + max_concurrent=max_concurrent, + ) diff --git a/src/everos/component/llm/__init__.py b/src/everos/component/llm/__init__.py index c82baf5e9..bc9ae1aae 100644 --- a/src/everos/component/llm/__init__.py +++ b/src/everos/component/llm/__init__.py @@ -11,6 +11,8 @@ - :class:`LLMNotConfiguredError` — raised when credentials are missing. - :class:`OpenAIProvider` — concrete provider wrapping ``openai.AsyncOpenAI`` against any OpenAI-compatible endpoint. +- :class:`OllamaProvider` — local Ollama LLM provider with sensible + defaults (``http://localhost:11434/v1``, ``"ollama"`` key). - :func:`build_llm_provider` — settings-driven factory. - :func:`get_llm_client` — process-wide lazy singleton accessor. @@ -24,6 +26,7 @@ from .client import get_llm_client as get_llm_client from .client import get_multimodal_llm_client as get_multimodal_llm_client from .factory import build_llm_provider as build_llm_provider +from .ollama_provider import OllamaProvider as OllamaProvider from .openai_provider import OpenAIProvider as OpenAIProvider from .protocol import ChatMessage as ChatMessage from .protocol import ChatResponse as ChatResponse @@ -37,6 +40,7 @@ "LLMClient", "LLMError", "LLMNotConfiguredError", + "OllamaProvider", "OpenAIProvider", "Usage", "build_llm_provider", diff --git a/src/everos/component/llm/ollama_provider.py b/src/everos/component/llm/ollama_provider.py new file mode 100644 index 000000000..657742f7c --- /dev/null +++ b/src/everos/component/llm/ollama_provider.py @@ -0,0 +1,61 @@ +"""Ollama LLM provider for everos. + +Ollama exposes an OpenAI-compatible API at ``http://localhost:11434/v1`` +by default, with ``api-key`` accepting any non-empty value. This +provider extends :class:`OpenAIProvider` with Ollama-specific defaults +so callers don't need to remember the canonical base URL or dummy key. + +Usage:: + settings = LLMSettings( + model="llama3.1", + base_url="http://localhost:11434/v1", # or omit — Ollama default + api_key=SecretStr("ollama"), # or omit — Ollama default + ) + provider = OllamaProvider(model=settings.model) +""" + +from __future__ import annotations + +from typing import Any + +from .openai_provider import OpenAIProvider +from .protocol import ChatMessage, ChatResponse + + +_OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1" +_OLLAMA_PLACEHOLDER_KEY = "ollama" + + +class OllamaProvider(OpenAIProvider): + """Ollama LLM provider — OpenAI-compatible with local defaults. + + Identical to :class:`OpenAIProvider` except that ``api_key`` and + ``base_url`` fall back to Ollama's conventional values when omitted. + + Args: + model: Ollama model id (e.g. ``"llama3.1"``, ``"qwen2.5"``). + api_key: API key; defaults to ``"ollama"`` (Ollama's placeholder). + base_url: Endpoint; defaults to ``http://localhost:11434/v1``. + timeout: Per-request timeout in seconds. + temperature: Default sampling temperature. + max_tokens: Default max-tokens cap. + """ + + def __init__( + self, + *, + model: str, + api_key: str = _OLLAMA_PLACEHOLDER_KEY, + base_url: str | None = _OLLAMA_DEFAULT_BASE_URL, + timeout: float = 60.0, + temperature: float = 0.0, + max_tokens: int | None = None, + ) -> None: + super().__init__( + model=model, + api_key=api_key, + base_url=base_url, + timeout=timeout, + temperature=temperature, + max_tokens=max_tokens, + ) diff --git a/tests/unit/test_component/test_ollama_providers.py b/tests/unit/test_component/test_ollama_providers.py new file mode 100644 index 000000000..a12045bb2 --- /dev/null +++ b/tests/unit/test_component/test_ollama_providers.py @@ -0,0 +1,106 @@ +"""Tests for Ollama LLM and Embedding providers.""" + +from __future__ import annotations + +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +# ── LLM Provider ──────────────────────────────────────────────────── + + +class TestOllamaProvider: + """Ollama LLM provider tests.""" + + def test_defaults(self): + """OllamaProvider applies localhost defaults when omitted.""" + from everos.component.llm.ollama_provider import OllamaProvider + + with patch("openai.AsyncOpenAI"): + p = OllamaProvider(model="llama3.1") + + assert p._model == "llama3.1" + + def test_custom_base_url(self): + """Custom base_url and api_key are respected.""" + from everos.component.llm.ollama_provider import OllamaProvider + + with patch("openai.AsyncOpenAI"): + p = OllamaProvider( + model="qwen2.5", + api_key="custom-key", + base_url="http://192.168.1.100:11434/v1", + ) + + assert p._model == "qwen2.5" + + def test_extends_openai_provider(self): + """OllamaProvider inherits from OpenAIProvider.""" + from everos.component.llm.ollama_provider import OllamaProvider + from everos.component.llm.openai_provider import OpenAIProvider + + assert issubclass(OllamaProvider, OpenAIProvider) + + +# ── Embedding Provider ────────────────────────────────────────────── + + +class TestOllamaEmbeddingProvider: + """Ollama Embedding provider tests.""" + + def test_defaults(self): + """OllamaEmbeddingProvider applies localhost + dim=768 defaults.""" + from everos.component.embedding.ollama_provider import ( + OllamaEmbeddingProvider, + ) + + with patch("openai.AsyncOpenAI"): + p = OllamaEmbeddingProvider(model="nomic-embed-text") + + assert p._model == "nomic-embed-text" + assert p.dim == 768 + + def test_custom_dim(self): + """Custom dim overrides the default 768.""" + from everos.component.embedding.ollama_provider import ( + OllamaEmbeddingProvider, + ) + + with patch("openai.AsyncOpenAI"): + p = OllamaEmbeddingProvider( + model="mxbai-embed-large", + dim=1024, + ) + + assert p.dim == 1024 + + def test_extends_openai_provider(self): + """OllamaEmbeddingProvider inherits from OpenAIEmbeddingProvider.""" + from everos.component.embedding.ollama_provider import ( + OllamaEmbeddingProvider, + ) + from everos.component.embedding.openai_provider import ( + OpenAIEmbeddingProvider, + ) + + assert issubclass(OllamaEmbeddingProvider, OpenAIEmbeddingProvider) + + +# ── Import surface ────────────────────────────────────────────────── + + +class TestImportSurface: + """Verify public imports work from the package level.""" + + def test_llm_ollama_importable(self): + """OllamaProvider is importable from everos.component.llm.""" + from everos.component.llm import OllamaProvider + + assert OllamaProvider is not None + + def test_embedding_ollama_importable(self): + """OllamaEmbeddingProvider is importable from everos.component.embedding.""" + from everos.component.embedding import OllamaEmbeddingProvider + + assert OllamaEmbeddingProvider is not None