7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
861 lines
34 KiB
Python
861 lines
34 KiB
Python
"""
|
|
Tests for Ollama embedding provider.
|
|
"""
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
import requests
|
|
|
|
from local_deep_research.embeddings.providers.implementations.ollama import (
|
|
OllamaEmbeddingsProvider,
|
|
)
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
_OLLAMA_MODULE = (
|
|
"local_deep_research.embeddings.providers.implementations.ollama"
|
|
)
|
|
_LLM_UTILS = "local_deep_research.utilities.llm_utils"
|
|
|
|
|
|
def _mock_capabilities(caps_by_model):
|
|
"""Return a side_effect function for _get_model_capabilities.
|
|
|
|
``caps_by_model`` maps model names to capability lists
|
|
(e.g. {"nomic-embed-text:latest": ["embedding"]}).
|
|
"""
|
|
|
|
def _side_effect(_base_url, model_name):
|
|
return caps_by_model.get(model_name)
|
|
|
|
return _side_effect
|
|
|
|
|
|
# ── metadata ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestOllamaEmbeddingsProviderMetadata:
|
|
"""Tests for OllamaEmbeddingsProvider class metadata."""
|
|
|
|
def test_provider_name(self):
|
|
"""Provider name is 'Ollama'."""
|
|
assert OllamaEmbeddingsProvider.provider_name == "Ollama"
|
|
|
|
def test_provider_key(self):
|
|
"""Provider key is 'OLLAMA'."""
|
|
assert OllamaEmbeddingsProvider.provider_key == "OLLAMA"
|
|
|
|
def test_requires_api_key_false(self):
|
|
"""Does not require API key."""
|
|
assert OllamaEmbeddingsProvider.requires_api_key is False
|
|
|
|
def test_supports_local_true(self):
|
|
"""Supports local execution."""
|
|
assert OllamaEmbeddingsProvider.supports_local is True
|
|
|
|
def test_default_model(self):
|
|
"""Has a default embedding model."""
|
|
assert OllamaEmbeddingsProvider.default_model == "nomic-embed-text"
|
|
|
|
|
|
# ── is_available ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestOllamaEmbeddingsIsAvailable:
|
|
"""Tests for is_available method."""
|
|
|
|
def test_available_when_server_responds(self):
|
|
"""Returns True when Ollama server responds."""
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch("requests.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_get.return_value = mock_response
|
|
|
|
result = OllamaEmbeddingsProvider.is_available()
|
|
assert result is True
|
|
|
|
def test_not_available_when_server_error(self):
|
|
"""Returns False when server returns error."""
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch("requests.get") as mock_get:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 500
|
|
mock_get.return_value = mock_response
|
|
|
|
result = OllamaEmbeddingsProvider.is_available()
|
|
assert result is False
|
|
|
|
def test_not_available_when_connection_fails(self):
|
|
"""Returns False when connection fails."""
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.ConnectionError()
|
|
|
|
result = OllamaEmbeddingsProvider.is_available()
|
|
assert result is False
|
|
|
|
def test_not_available_when_timeout(self):
|
|
"""Returns False when request times out."""
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.Timeout()
|
|
|
|
result = OllamaEmbeddingsProvider.is_available()
|
|
assert result is False
|
|
|
|
|
|
# ── create_embeddings ────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_settings_side_effect(values):
|
|
"""Build a side_effect for get_setting_from_snapshot that dispatches by key.
|
|
|
|
``values`` maps setting keys (e.g. "embeddings.ollama.model") to the value
|
|
that lookup should return. Keys absent from ``values`` fall back to the
|
|
caller-provided ``default`` argument.
|
|
"""
|
|
|
|
def _side_effect(key, default=None, **_kwargs):
|
|
return values.get(key, default)
|
|
|
|
return _side_effect
|
|
|
|
|
|
class TestOllamaEmbeddingsCreate:
|
|
"""Tests for create_embeddings method."""
|
|
|
|
def test_create_with_default_model(self):
|
|
"""Creates embeddings with default model."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 8192,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
mock_instance = Mock()
|
|
mock_ollama.return_value = mock_instance
|
|
|
|
result = OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
assert result is mock_instance
|
|
mock_ollama.assert_called_once()
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["model"] == "nomic-embed-text"
|
|
|
|
def test_create_with_custom_model(self):
|
|
"""Creates embeddings with custom model."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{"embeddings.ollama.num_ctx": 8192}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
mock_instance = Mock()
|
|
mock_ollama.return_value = mock_instance
|
|
|
|
OllamaEmbeddingsProvider.create_embeddings(
|
|
model="mxbai-embed-large"
|
|
)
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["model"] == "mxbai-embed-large"
|
|
|
|
def test_create_with_custom_base_url(self):
|
|
"""Creates embeddings with custom base URL."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{"embeddings.ollama.num_ctx": 8192}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
mock_instance = Mock()
|
|
mock_ollama.return_value = mock_instance
|
|
|
|
OllamaEmbeddingsProvider.create_embeddings(
|
|
model="nomic-embed-text",
|
|
base_url="http://custom:8080",
|
|
)
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["base_url"] == "http://custom:8080"
|
|
|
|
def test_create_uses_settings_snapshot(self):
|
|
"""Uses settings snapshot when provided."""
|
|
mock_settings = {"embeddings.ollama.model": "custom-model"}
|
|
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "custom-model",
|
|
"embeddings.ollama.num_ctx": 8192,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings"):
|
|
OllamaEmbeddingsProvider.create_embeddings(
|
|
settings_snapshot=mock_settings
|
|
)
|
|
|
|
# Verify get_setting_from_snapshot was called with settings
|
|
mock_get_setting.assert_called()
|
|
|
|
def test_create_passes_num_ctx_when_set(self):
|
|
"""Forwards num_ctx to OllamaEmbeddings when configured."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 8192,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["num_ctx"] == 8192
|
|
|
|
def test_create_omits_num_ctx_when_unset(self):
|
|
"""Does not pass num_ctx when the setting is None."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": None,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert "num_ctx" not in call_kwargs
|
|
|
|
def test_create_passes_custom_num_ctx(self):
|
|
"""Forwards a non-default num_ctx (proves the value is pass-through, not hardcoded)."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 16384,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["num_ctx"] == 16384
|
|
|
|
def test_create_coerces_string_num_ctx_to_int(self):
|
|
"""Coerces string num_ctx to int (settings storage may return strings)."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": "8192",
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["num_ctx"] == 8192
|
|
assert isinstance(call_kwargs["num_ctx"], int)
|
|
|
|
def test_create_coerces_float_num_ctx_to_int(self):
|
|
"""Coerces float num_ctx to int (JSON deserialization may yield floats)."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 8192.0,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["num_ctx"] == 8192
|
|
assert isinstance(call_kwargs["num_ctx"], int)
|
|
|
|
def test_create_combines_custom_model_and_num_ctx(self):
|
|
"""Custom model arg and num_ctx setting both flow through."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{"embeddings.ollama.num_ctx": 4096}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings(
|
|
model="bge-m3",
|
|
)
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["model"] == "bge-m3"
|
|
assert call_kwargs["num_ctx"] == 4096
|
|
|
|
def test_create_uses_default_num_ctx_when_snapshot_missing_key(self):
|
|
"""Falls back to default=8192 when snapshot doesn't contain num_ctx."""
|
|
# _make_settings_side_effect returns the caller's `default=` for
|
|
# absent keys, mirroring what the real settings system does when
|
|
# neither DB nor JSON defaults have the key (shouldn't happen in
|
|
# practice once the JSON ships, but verifies the explicit default
|
|
# in create_embeddings is wired correctly).
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{"embeddings.ollama.model": "nomic-embed-text"}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.OllamaEmbeddings") as mock_ollama:
|
|
OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
call_kwargs = mock_ollama.call_args[1]
|
|
assert call_kwargs["num_ctx"] == 8192
|
|
|
|
|
|
# ── weakref.finalize safety net ─────────────────────────────────────────
|
|
|
|
|
|
class TestOllamaEmbeddingsFinalizerSafetyNet:
|
|
"""The provider factory registers a ``weakref.finalize`` so callers
|
|
that bypass ``LocalEmbeddingManager`` (e.g. the programmatic-API
|
|
example scripts under ``examples/api_usage/``, direct test
|
|
constructions) still get cleanup at GC time.
|
|
|
|
The manager-driven explicit close path remains the load-bearing
|
|
primary cleanup; the finalizer is the last-resort safety net only.
|
|
"""
|
|
|
|
def test_create_registers_finalizer_with_inner_clients(self):
|
|
"""Provider passes the inner sync/async ``ollama.Client`` objects to
|
|
``weakref.finalize`` — not the wrapping ``OllamaEmbeddings`` itself.
|
|
Passing the instance would defeat the finalizer's purpose by keeping
|
|
the instance alive through the registry's strong-ref."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 8192,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
# A simple object (not Mock) so the auto-attr behavior of
|
|
# Mock doesn't mask a missing _client/_async_client.
|
|
class _FakeOllamaEmbeddings:
|
|
def __init__(self):
|
|
self._client = object()
|
|
self._async_client = object()
|
|
|
|
fake_instance = _FakeOllamaEmbeddings()
|
|
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.OllamaEmbeddings",
|
|
return_value=fake_instance,
|
|
):
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.weakref.finalize"
|
|
) as mock_finalize:
|
|
result = OllamaEmbeddingsProvider.create_embeddings()
|
|
|
|
assert result is fake_instance
|
|
mock_finalize.assert_called_once()
|
|
args, _ = mock_finalize.call_args
|
|
# weakref.finalize(instance, callback, sync, async)
|
|
assert args[0] is fake_instance
|
|
assert args[2] is fake_instance._client
|
|
assert args[3] is fake_instance._async_client
|
|
|
|
def test_finalizer_does_not_crash_if_inner_attrs_missing(self):
|
|
"""A future langchain_ollama version might rename _client /
|
|
_async_client. The factory must not crash in that case — the
|
|
explicit close path (or the upstream's own cleanup) remains
|
|
responsible for resource release.
|
|
"""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 8192,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:11434"
|
|
|
|
# Slotted class with no _client / _async_client → attribute
|
|
# access raises AttributeError, exercising the except branch.
|
|
class _ReshapedEmbeddings:
|
|
__slots__ = ()
|
|
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.OllamaEmbeddings",
|
|
return_value=_ReshapedEmbeddings(),
|
|
):
|
|
# Must not raise.
|
|
result = OllamaEmbeddingsProvider.create_embeddings()
|
|
assert result is not None
|
|
|
|
def test_real_ollama_embeddings_finalizer_closes_clients_on_gc(self):
|
|
"""End-to-end: construct a real ``langchain_ollama.OllamaEmbeddings``
|
|
via the provider, drop the reference, force GC, and verify both
|
|
inner httpx clients are closed. This is the canary for the
|
|
#3816-shaped FD leak on the embeddings side.
|
|
"""
|
|
import gc
|
|
import weakref as _weakref
|
|
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_setting_from_snapshot"
|
|
) as mock_get_setting:
|
|
mock_get_setting.side_effect = _make_settings_side_effect(
|
|
{
|
|
"embeddings.ollama.model": "nomic-embed-text",
|
|
"embeddings.ollama.num_ctx": 8192,
|
|
}
|
|
)
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.get_ollama_base_url") as mock_get_url:
|
|
mock_get_url.return_value = "http://localhost:1"
|
|
|
|
# No OllamaEmbeddings patch — uses the real class.
|
|
instance = OllamaEmbeddingsProvider.create_embeddings()
|
|
sync_httpx = instance._client._client
|
|
async_httpx = instance._async_client._client
|
|
assert sync_httpx.is_closed is False
|
|
assert async_httpx.is_closed is False
|
|
|
|
instance_ref = _weakref.ref(instance)
|
|
|
|
# End the patches before dropping the ref so the finalizer
|
|
# runs against the real ``_close_inner_ollama_clients``.
|
|
del instance
|
|
gc.collect()
|
|
|
|
assert instance_ref() is None, (
|
|
"OllamaEmbeddings instance must be GC'able — the finalizer "
|
|
"must not strong-ref it"
|
|
)
|
|
assert sync_httpx.is_closed is True
|
|
assert async_httpx.is_closed is True
|
|
|
|
|
|
# ── _get_model_capabilities ─────────────────────────────────────────────
|
|
|
|
|
|
class TestGetModelCapabilities:
|
|
"""Tests for _get_model_capabilities private helper."""
|
|
|
|
def test_returns_capabilities_on_success(self):
|
|
"""Returns capability list from /api/show response."""
|
|
mock_resp = Mock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"capabilities": ["embedding"]}
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.safe_post", return_value=mock_resp):
|
|
caps = OllamaEmbeddingsProvider._get_model_capabilities(
|
|
"http://localhost:11434", "nomic-embed-text"
|
|
)
|
|
assert caps == ["embedding"]
|
|
|
|
def test_returns_none_on_http_error(self):
|
|
"""Returns None when server returns non-200."""
|
|
mock_resp = Mock()
|
|
mock_resp.status_code = 404
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.safe_post", return_value=mock_resp):
|
|
caps = OllamaEmbeddingsProvider._get_model_capabilities(
|
|
"http://localhost:11434", "nonexistent-model"
|
|
)
|
|
assert caps is None
|
|
|
|
def test_returns_none_on_exception(self):
|
|
"""Returns None when request raises exception."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.safe_post",
|
|
side_effect=Exception("connection refused"),
|
|
):
|
|
caps = OllamaEmbeddingsProvider._get_model_capabilities(
|
|
"http://localhost:11434", "nomic-embed-text"
|
|
)
|
|
assert caps is None
|
|
|
|
def test_returns_none_when_capabilities_missing(self):
|
|
"""Returns None when response lacks capabilities field."""
|
|
mock_resp = Mock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"details": {"family": "nomic-bert"}}
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.safe_post", return_value=mock_resp):
|
|
caps = OllamaEmbeddingsProvider._get_model_capabilities(
|
|
"http://localhost:11434", "nomic-embed-text"
|
|
)
|
|
assert caps is None
|
|
|
|
def test_returns_completion_for_llm(self):
|
|
"""Returns completion capabilities for LLM models."""
|
|
mock_resp = Mock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"capabilities": ["completion", "tools"]}
|
|
|
|
with patch(f"{_OLLAMA_MODULE}.safe_post", return_value=mock_resp):
|
|
caps = OllamaEmbeddingsProvider._get_model_capabilities(
|
|
"http://localhost:11434", "qwen3:4b"
|
|
)
|
|
assert caps == ["completion", "tools"]
|
|
|
|
|
|
# ── is_embedding_model ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestIsEmbeddingModel:
|
|
"""Tests for is_embedding_model method."""
|
|
|
|
def test_returns_true_for_embedding_model(self):
|
|
"""Returns True when capabilities include 'embedding'."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_ollama_base_url",
|
|
return_value="http://localhost:11434",
|
|
):
|
|
with patch.object(
|
|
OllamaEmbeddingsProvider,
|
|
"_get_model_capabilities",
|
|
return_value=["embedding"],
|
|
):
|
|
result = OllamaEmbeddingsProvider.is_embedding_model(
|
|
"nomic-embed-text"
|
|
)
|
|
assert result is True
|
|
|
|
def test_returns_false_for_llm_model(self):
|
|
"""Returns False when capabilities don't include 'embedding'."""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_ollama_base_url",
|
|
return_value="http://localhost:11434",
|
|
):
|
|
with patch.object(
|
|
OllamaEmbeddingsProvider,
|
|
"_get_model_capabilities",
|
|
return_value=["completion", "tools"],
|
|
):
|
|
result = OllamaEmbeddingsProvider.is_embedding_model("qwen3:4b")
|
|
assert result is False
|
|
|
|
def test_returns_none_when_capabilities_unavailable(self):
|
|
"""Older Ollama (no capabilities in /api/show) → None, not a guess.
|
|
|
|
We refuse to guess from the model name; the caller can decide
|
|
whether to tag the model, hide it, or just trust the user.
|
|
"""
|
|
with patch(
|
|
f"{_OLLAMA_MODULE}.get_ollama_base_url",
|
|
return_value="http://localhost:11434",
|
|
):
|
|
with patch.object(
|
|
OllamaEmbeddingsProvider,
|
|
"_get_model_capabilities",
|
|
return_value=None,
|
|
):
|
|
assert (
|
|
OllamaEmbeddingsProvider.is_embedding_model(
|
|
"nomic-embed-text"
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
OllamaEmbeddingsProvider.is_embedding_model(
|
|
"deepseek-r1:32b"
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
# ── get_available_models ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestOllamaEmbeddingsGetAvailableModels:
|
|
"""Tests for get_available_models method."""
|
|
|
|
def _setup_mocks(self, all_models, caps_by_model):
|
|
"""Helper: patch base_url, fetch_ollama_models, and capabilities."""
|
|
return (
|
|
patch(
|
|
f"{_OLLAMA_MODULE}.get_ollama_base_url",
|
|
return_value="http://localhost:11434",
|
|
),
|
|
patch(
|
|
f"{_LLM_UTILS}.fetch_ollama_models",
|
|
return_value=all_models,
|
|
),
|
|
patch.object(
|
|
OllamaEmbeddingsProvider,
|
|
"_get_model_capabilities",
|
|
side_effect=_mock_capabilities(caps_by_model),
|
|
),
|
|
)
|
|
|
|
def test_embedding_models_sorted_first(self):
|
|
"""Embedding models appear before LLM models."""
|
|
all_models = [
|
|
{"value": "qwen3:4b", "label": "qwen3:4b"},
|
|
{
|
|
"value": "nomic-embed-text:latest",
|
|
"label": "nomic-embed-text:latest",
|
|
},
|
|
{"value": "deepseek-r1:32b", "label": "deepseek-r1:32b"},
|
|
]
|
|
caps = {
|
|
"qwen3:4b": ["completion", "tools"],
|
|
"nomic-embed-text:latest": ["embedding"],
|
|
"deepseek-r1:32b": ["completion"],
|
|
}
|
|
|
|
p1, p2, p3 = self._setup_mocks(all_models, caps)
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
assert result[0]["value"] == "nomic-embed-text:latest"
|
|
assert result[0]["is_embedding"] is True
|
|
|
|
def test_all_models_returned_with_flag(self):
|
|
"""All models are returned, each with an is_embedding flag."""
|
|
all_models = [
|
|
{
|
|
"value": "nomic-embed-text:latest",
|
|
"label": "nomic-embed-text:latest",
|
|
},
|
|
{"value": "qwen3:4b", "label": "qwen3:4b"},
|
|
]
|
|
caps = {
|
|
"nomic-embed-text:latest": ["embedding"],
|
|
"qwen3:4b": ["completion"],
|
|
}
|
|
|
|
p1, p2, p3 = self._setup_mocks(all_models, caps)
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
assert len(result) == 2
|
|
assert all("is_embedding" in m for m in result)
|
|
|
|
embed_models = [m for m in result if m["is_embedding"]]
|
|
llm_models = [m for m in result if not m["is_embedding"]]
|
|
assert len(embed_models) == 1
|
|
assert len(llm_models) == 1
|
|
|
|
def test_empty_model_list(self):
|
|
"""Returns empty list when no models available."""
|
|
p1, p2, p3 = self._setup_mocks([], {})
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
assert result == []
|
|
|
|
def test_all_llms_still_returned(self):
|
|
"""Even if no embedding models exist, all LLMs are returned."""
|
|
all_models = [
|
|
{"value": "qwen3:4b", "label": "qwen3:4b"},
|
|
{"value": "deepseek-r1:32b", "label": "deepseek-r1:32b"},
|
|
]
|
|
caps = {
|
|
"qwen3:4b": ["completion"],
|
|
"deepseek-r1:32b": ["completion"],
|
|
}
|
|
|
|
p1, p2, p3 = self._setup_mocks(all_models, caps)
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
assert len(result) == 2
|
|
assert all(m["is_embedding"] is False for m in result)
|
|
|
|
def test_capabilities_unavailable_returns_untagged_models(self):
|
|
"""Older Ollama (no /api/show capabilities) → every model is
|
|
still listed, just without an ``is_embedding`` flag. The
|
|
provider doesn't guess from the name."""
|
|
all_models = [
|
|
{
|
|
"value": "nomic-embed-text:latest",
|
|
"label": "nomic-embed-text:latest",
|
|
},
|
|
{"value": "qwen3:4b", "label": "qwen3:4b"},
|
|
]
|
|
caps = {} # No model has capabilities → all untagged
|
|
|
|
p1, p2, p3 = self._setup_mocks(all_models, caps)
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
assert {m["value"] for m in result} == {
|
|
"nomic-embed-text:latest",
|
|
"qwen3:4b",
|
|
}
|
|
assert all("is_embedding" not in m for m in result)
|
|
|
|
def test_multiple_embedding_models(self):
|
|
"""Multiple embedding models are all sorted first."""
|
|
all_models = [
|
|
{"value": "qwen3:4b", "label": "qwen3:4b"},
|
|
{
|
|
"value": "nomic-embed-text:latest",
|
|
"label": "nomic-embed-text:latest",
|
|
},
|
|
{
|
|
"value": "mxbai-embed-large:latest",
|
|
"label": "mxbai-embed-large:latest",
|
|
},
|
|
{"value": "deepseek-r1:32b", "label": "deepseek-r1:32b"},
|
|
]
|
|
caps = {
|
|
"qwen3:4b": ["completion"],
|
|
"nomic-embed-text:latest": ["embedding"],
|
|
"mxbai-embed-large:latest": ["embedding"],
|
|
"deepseek-r1:32b": ["completion"],
|
|
}
|
|
|
|
p1, p2, p3 = self._setup_mocks(all_models, caps)
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
# First two should be embedding models
|
|
assert result[0]["is_embedding"] is True
|
|
assert result[1]["is_embedding"] is True
|
|
assert result[2]["is_embedding"] is False
|
|
assert result[3]["is_embedding"] is False
|
|
|
|
def test_result_preserves_value_and_label(self):
|
|
"""Original value and label fields are preserved."""
|
|
all_models = [
|
|
{
|
|
"value": "nomic-embed-text:latest",
|
|
"label": "nomic-embed-text:latest",
|
|
},
|
|
]
|
|
caps = {"nomic-embed-text:latest": ["embedding"]}
|
|
|
|
p1, p2, p3 = self._setup_mocks(all_models, caps)
|
|
with p1, p2, p3:
|
|
result = OllamaEmbeddingsProvider.get_available_models()
|
|
|
|
assert result[0]["value"] == "nomic-embed-text:latest"
|
|
assert result[0]["label"] == "nomic-embed-text:latest"
|
|
assert result[0]["is_embedding"] is True
|
|
|
|
|
|
# ── provider info & config ───────────────────────────────────────────────
|
|
|
|
|
|
class TestOllamaEmbeddingsProviderInfo:
|
|
"""Tests for get_provider_info method."""
|
|
|
|
def test_provider_info_structure(self):
|
|
"""get_provider_info returns expected structure."""
|
|
info = OllamaEmbeddingsProvider.get_provider_info()
|
|
|
|
assert "name" in info
|
|
assert "key" in info
|
|
assert "requires_api_key" in info
|
|
assert "supports_local" in info
|
|
assert "default_model" in info
|
|
|
|
assert info["name"] == "Ollama"
|
|
assert info["key"] == "OLLAMA"
|
|
assert info["requires_api_key"] is False
|
|
assert info["supports_local"] is True
|
|
|
|
|
|
class TestOllamaEmbeddingsValidateConfig:
|
|
"""Tests for validate_config method."""
|
|
|
|
def test_validate_config_when_available(self):
|
|
"""validate_config returns True when available."""
|
|
with patch.object(
|
|
OllamaEmbeddingsProvider, "is_available", return_value=True
|
|
):
|
|
is_valid, error = OllamaEmbeddingsProvider.validate_config()
|
|
assert is_valid is True
|
|
assert error is None
|
|
|
|
def test_validate_config_when_not_available(self):
|
|
"""validate_config returns False when not available."""
|
|
with patch.object(
|
|
OllamaEmbeddingsProvider, "is_available", return_value=False
|
|
):
|
|
is_valid, error = OllamaEmbeddingsProvider.validate_config()
|
|
assert is_valid is False
|
|
assert error is not None
|
|
assert "not available" in error
|