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
728 lines
28 KiB
Python
728 lines
28 KiB
Python
"""
|
|
Tests for embeddings/providers/implementations/openai.py
|
|
|
|
Tests cover:
|
|
- OpenAIEmbeddingsProvider.create_embeddings()
|
|
- OpenAIEmbeddingsProvider.is_available()
|
|
- OpenAIEmbeddingsProvider.get_available_models()
|
|
- Class attributes and metadata
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
class TestOpenAIEmbeddingsProviderMetadata:
|
|
"""Tests for OpenAIEmbeddingsProvider class metadata."""
|
|
|
|
def test_provider_name(self):
|
|
"""Test provider name is set correctly."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
assert OpenAIEmbeddingsProvider.provider_name == "OpenAI"
|
|
|
|
def test_provider_key(self):
|
|
"""Test provider key is set correctly."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
assert OpenAIEmbeddingsProvider.provider_key == "OPENAI"
|
|
|
|
def test_requires_api_key(self):
|
|
"""Provider does not strictly require an API key.
|
|
|
|
Cloud OpenAI does need one, but OpenAI-compatible local servers
|
|
(LM Studio, vLLM, llama.cpp) don't — the runtime path in
|
|
``is_available`` / ``create_embeddings`` enforces the rule when
|
|
a base_url is *not* set. The class-level flag therefore stays
|
|
``False`` (inherited from BaseEmbeddingProvider) so any future
|
|
UI consumer doesn't show a misleading "API key required" badge
|
|
for keyless local-server users.
|
|
"""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
assert OpenAIEmbeddingsProvider.requires_api_key is False
|
|
|
|
def test_supports_local(self):
|
|
"""Test that OpenAI does not support local."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
assert OpenAIEmbeddingsProvider.supports_local is False
|
|
|
|
def test_default_model(self):
|
|
"""Test default model is set."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
assert (
|
|
OpenAIEmbeddingsProvider.default_model == "text-embedding-3-small"
|
|
)
|
|
|
|
|
|
class TestOpenAIEmbeddingsProviderCreateEmbeddings:
|
|
"""Tests for OpenAIEmbeddingsProvider.create_embeddings method."""
|
|
|
|
def test_create_embeddings_with_api_key(self):
|
|
"""Test creating embeddings with API key provided."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
|
|
# Mock get_setting_from_snapshot to return None for other settings
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
) as mock_class:
|
|
result = OpenAIEmbeddingsProvider.create_embeddings(
|
|
model="text-embedding-3-small",
|
|
api_key="test-api-key",
|
|
)
|
|
|
|
assert result is mock_embeddings
|
|
mock_class.assert_called_once()
|
|
call_kwargs = mock_class.call_args[1]
|
|
assert call_kwargs["model"] == "text-embedding-3-small"
|
|
assert call_kwargs["openai_api_key"] == "test-api-key"
|
|
# No base_url → LangChain default ctx-check is preserved.
|
|
assert "check_embedding_ctx_length" not in call_kwargs
|
|
|
|
def test_create_embeddings_missing_api_key_raises(self):
|
|
"""Test that missing API key raises ValueError."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with pytest.raises(ValueError, match="API key not configured"):
|
|
OpenAIEmbeddingsProvider.create_embeddings()
|
|
|
|
def test_create_embeddings_with_settings_snapshot(self):
|
|
"""Test creating embeddings with settings snapshot."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
settings = {"embeddings.openai.api_key": "snapshot-key"}
|
|
|
|
def mock_get_setting(key, default=None, settings_snapshot=None):
|
|
if key == "embeddings.openai.api_key":
|
|
return "snapshot-key"
|
|
return default
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=mock_get_setting,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
):
|
|
result = OpenAIEmbeddingsProvider.create_embeddings(
|
|
settings_snapshot=settings
|
|
)
|
|
|
|
assert result is mock_embeddings
|
|
|
|
def test_create_embeddings_with_base_url(self):
|
|
"""Test creating embeddings with custom base URL."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
) as mock_class:
|
|
OpenAIEmbeddingsProvider.create_embeddings(
|
|
api_key="test-key",
|
|
base_url="https://custom.openai.com",
|
|
)
|
|
|
|
call_kwargs = mock_class.call_args[1]
|
|
assert (
|
|
call_kwargs["openai_api_base"]
|
|
== "https://custom.openai.com"
|
|
)
|
|
assert call_kwargs["check_embedding_ctx_length"] is False
|
|
|
|
def test_create_embeddings_with_official_openai_base_url_keeps_ctx_check(
|
|
self,
|
|
):
|
|
"""Explicit base_url pointing at api.openai.com must NOT disable the
|
|
client-side context-length check; that guard is only meant to be
|
|
skipped for non-OpenAI hosts (LM Studio, vLLM, llama.cpp, etc.)."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
) as mock_class:
|
|
OpenAIEmbeddingsProvider.create_embeddings(
|
|
api_key="test-key",
|
|
base_url="https://api.openai.com/v1",
|
|
)
|
|
|
|
call_kwargs = mock_class.call_args[1]
|
|
assert (
|
|
call_kwargs["openai_api_base"]
|
|
== "https://api.openai.com/v1"
|
|
)
|
|
assert "check_embedding_ctx_length" not in call_kwargs
|
|
|
|
def test_create_embeddings_with_schemeless_openai_base_url_keeps_ctx_check(
|
|
self,
|
|
):
|
|
"""A scheme-less base_url like ``api.openai.com`` must still be
|
|
recognized as the real OpenAI endpoint. ``urlparse`` on a bare
|
|
host returns ``hostname=None``, so without URL normalization the
|
|
ctx-length guard would silently be dropped for the cloud API."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
) as mock_class:
|
|
OpenAIEmbeddingsProvider.create_embeddings(
|
|
api_key="test-key",
|
|
base_url="api.openai.com",
|
|
)
|
|
|
|
call_kwargs = mock_class.call_args[1]
|
|
# normalize_url prepends https:// for external hosts.
|
|
assert (
|
|
call_kwargs["openai_api_base"] == "https://api.openai.com"
|
|
)
|
|
assert "check_embedding_ctx_length" not in call_kwargs
|
|
|
|
def test_create_embeddings_with_dimensions(self):
|
|
"""Test creating embeddings with custom dimensions for v3 model."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
) as mock_class:
|
|
OpenAIEmbeddingsProvider.create_embeddings(
|
|
model="text-embedding-3-small",
|
|
api_key="test-key",
|
|
dimensions=256,
|
|
)
|
|
|
|
call_kwargs = mock_class.call_args[1]
|
|
assert call_kwargs["dimensions"] == 256
|
|
|
|
def test_create_embeddings_dimensions_ignored_for_non_v3_model(self):
|
|
"""Test that dimensions are ignored for non-v3 models."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_embeddings = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=mock_embeddings,
|
|
) as mock_class:
|
|
OpenAIEmbeddingsProvider.create_embeddings(
|
|
model="text-embedding-ada-002",
|
|
api_key="test-key",
|
|
dimensions=256,
|
|
)
|
|
|
|
call_kwargs = mock_class.call_args[1]
|
|
assert "dimensions" not in call_kwargs
|
|
|
|
|
|
class TestOpenAIEmbeddingsProviderIsAvailable:
|
|
"""Tests for OpenAIEmbeddingsProvider.is_available method."""
|
|
|
|
def test_is_available_with_api_key(self):
|
|
"""Test that provider is available when API key is set."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value="test-api-key",
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is True
|
|
|
|
def test_is_available_without_api_key(self):
|
|
"""Test that provider is not available without API key."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is False
|
|
|
|
def test_is_available_with_empty_api_key(self):
|
|
"""Test that provider is not available with empty API key."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value="",
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is False
|
|
|
|
def test_is_available_exception_returns_false(self):
|
|
"""Test that exception during availability check returns False."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=Exception("Settings error"),
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is False
|
|
|
|
|
|
class TestOpenAIEmbeddingsProviderGetAvailableModels:
|
|
"""Tests for OpenAIEmbeddingsProvider.get_available_models method."""
|
|
|
|
def test_get_available_models_success(self):
|
|
"""Test getting available models from OpenAI API.
|
|
|
|
The provider returns every model the endpoint reports — there's
|
|
no reliable signal in /v1/models for "is this an embedding
|
|
model?", so guessing from the name is left to the user.
|
|
"""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
mock_model1 = MagicMock()
|
|
mock_model1.id = "text-embedding-3-small"
|
|
mock_model2 = MagicMock()
|
|
mock_model2.id = "text-embedding-3-large"
|
|
mock_model3 = MagicMock()
|
|
mock_model3.id = "gpt-4"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data = [mock_model1, mock_model2, mock_model3]
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.models.list.return_value = mock_response
|
|
|
|
def _settings(key, default=None, settings_snapshot=None):
|
|
if key == "embeddings.openai.api_key":
|
|
return "test-api-key"
|
|
return default
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=_settings,
|
|
):
|
|
with patch(
|
|
"openai.OpenAI",
|
|
return_value=mock_client,
|
|
):
|
|
models = OpenAIEmbeddingsProvider.get_available_models()
|
|
|
|
assert [m["value"] for m in models] == [
|
|
"text-embedding-3-small",
|
|
"text-embedding-3-large",
|
|
"gpt-4",
|
|
]
|
|
# No name-based tagging — the endpoint can't be trusted
|
|
# to identify embedding models for us.
|
|
assert all("is_embedding" not in m for m in models)
|
|
|
|
def test_get_available_models_no_api_key(self):
|
|
"""Test getting models returns empty list when no API key."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
models = OpenAIEmbeddingsProvider.get_available_models()
|
|
assert models == []
|
|
|
|
def test_get_available_models_api_error(self):
|
|
"""Test getting models returns empty list on API error."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
return_value="test-api-key",
|
|
):
|
|
with patch(
|
|
"openai.OpenAI",
|
|
side_effect=Exception("API error"),
|
|
):
|
|
models = OpenAIEmbeddingsProvider.get_available_models()
|
|
assert models == []
|
|
|
|
|
|
class TestOpenAIEmbeddingsProviderCompatibleEndpoint:
|
|
"""Regression tests for issue #3883.
|
|
|
|
OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp) speak
|
|
the same wire protocol as the OpenAI API but typically run on
|
|
``http://localhost:<port>/v1`` and do not require an API key. The
|
|
provider must:
|
|
|
|
1. report ``is_available() == True`` when the user has set a
|
|
``base_url`` even with no API key (so the UI lists it);
|
|
2. accept a missing API key at ``create_embeddings`` time as long
|
|
as a base_url is set, falling back to a placeholder so the
|
|
OpenAI client request still goes out;
|
|
3. still raise the configuration error when *neither* an API key
|
|
nor a base_url is set (so a blank install doesn't silently
|
|
hit an unconfigured endpoint).
|
|
"""
|
|
|
|
@staticmethod
|
|
def _settings_mock(api_key, base_url):
|
|
"""Return a side_effect callable for get_setting_from_snapshot.
|
|
|
|
The provider reads four ``embeddings.openai.*`` keys; this
|
|
helper routes each to a per-test value and returns ``default``
|
|
for anything else.
|
|
"""
|
|
|
|
values = {
|
|
"embeddings.openai.api_key": api_key,
|
|
"embeddings.openai.base_url": base_url,
|
|
}
|
|
|
|
def _side_effect(key, default=None, settings_snapshot=None):
|
|
if key in values:
|
|
return values[key]
|
|
return default
|
|
|
|
return _side_effect
|
|
|
|
def test_is_available_with_base_url_only(self):
|
|
"""Provider must be advertised when only base_url is set.
|
|
|
|
Red on master, green on branch — this is the headline
|
|
regression for issue #3883.
|
|
"""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(
|
|
api_key=None, base_url="http://localhost:1234/v1"
|
|
),
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is True
|
|
|
|
def test_is_available_with_blank_base_url_and_no_key(self):
|
|
"""Provider stays hidden on a blank install."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(api_key="", base_url=""),
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is False
|
|
|
|
def test_is_available_whitespace_only_settings_still_unavailable(self):
|
|
"""Whitespace-only values should not count as configured."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(api_key=" ", base_url="\t \n"),
|
|
):
|
|
assert OpenAIEmbeddingsProvider.is_available() is False
|
|
|
|
def test_create_embeddings_base_url_only_uses_placeholder_key(self):
|
|
"""No API key + base_url set → use placeholder, pass base_url through."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(
|
|
api_key=None,
|
|
base_url="http://host.docker.internal:1234/v1",
|
|
),
|
|
):
|
|
with patch(
|
|
"langchain_openai.OpenAIEmbeddings",
|
|
return_value=MagicMock(),
|
|
) as mock_class:
|
|
OpenAIEmbeddingsProvider.create_embeddings(
|
|
model="nomic-embed-text-v1.5",
|
|
)
|
|
|
|
call_kwargs = mock_class.call_args[1]
|
|
assert (
|
|
call_kwargs["openai_api_key"]
|
|
== OpenAIEmbeddingsProvider._PLACEHOLDER_API_KEY
|
|
)
|
|
assert (
|
|
call_kwargs["openai_api_base"]
|
|
== "http://host.docker.internal:1234/v1"
|
|
)
|
|
assert call_kwargs["check_embedding_ctx_length"] is False
|
|
|
|
def test_create_embeddings_error_message_mentions_base_url(self):
|
|
"""When both api_key and base_url are missing, the error must
|
|
tell the user about the local-server fallback path so they
|
|
don't go hunting for a key they don't have."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(api_key=None, base_url=None),
|
|
):
|
|
with pytest.raises(ValueError, match="base_url"):
|
|
OpenAIEmbeddingsProvider.create_embeddings()
|
|
|
|
def test_get_available_models_uses_base_url_when_no_key(self):
|
|
"""Model discovery on a keyless local server must route through
|
|
the configured base_url instead of api.openai.com."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
fake_client = MagicMock()
|
|
fake_client.models.list.return_value = MagicMock(data=[])
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(
|
|
api_key=None, base_url="http://localhost:1234/v1"
|
|
),
|
|
):
|
|
with patch(
|
|
"openai.OpenAI", return_value=fake_client
|
|
) as mock_openai:
|
|
OpenAIEmbeddingsProvider.get_available_models()
|
|
|
|
kwargs = mock_openai.call_args[1]
|
|
assert kwargs["base_url"] == "http://localhost:1234/v1"
|
|
assert (
|
|
kwargs["api_key"]
|
|
== OpenAIEmbeddingsProvider._PLACEHOLDER_API_KEY
|
|
)
|
|
|
|
|
|
class TestOpenAIEmbeddingsProviderLMStudioModelDiscovery:
|
|
"""Regression tests for issue #4195.
|
|
|
|
LM Studio (and other OpenAI-compatible local servers) returns every
|
|
loaded model from ``/v1/models``, both embedding models and LLMs,
|
|
without a reliable ``type`` field to distinguish them. The previous
|
|
``"embedding" in id`` filter dropped real embedding models whose
|
|
names use the shorter ``embed`` token (``nomic-embed-text-v1.5``)
|
|
and so the embedding-model dropdown ended up empty.
|
|
|
|
The fix: stop guessing from the name. Show every model the endpoint
|
|
returns and let the user pick the one they actually loaded.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _settings_mock(api_key, base_url):
|
|
values = {
|
|
"embeddings.openai.api_key": api_key,
|
|
"embeddings.openai.base_url": base_url,
|
|
}
|
|
|
|
def _side_effect(key, default=None, settings_snapshot=None):
|
|
if key in values:
|
|
return values[key]
|
|
return default
|
|
|
|
return _side_effect
|
|
|
|
def _fake_models_response(self, ids):
|
|
models = []
|
|
for model_id in ids:
|
|
m = MagicMock()
|
|
m.id = model_id
|
|
models.append(m)
|
|
response = MagicMock()
|
|
response.data = models
|
|
return response
|
|
|
|
def test_lmstudio_nomic_embed_text_is_listed(self):
|
|
"""The headline regression for #4195: a Nomic embedding model
|
|
loaded in LM Studio must appear in the dropdown — its name
|
|
doesn't contain the literal ``embedding`` token but it is the
|
|
embedding model the user wants to use."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
fake_client = MagicMock()
|
|
fake_client.models.list.return_value = self._fake_models_response(
|
|
["nomic-embed-text-v1.5"]
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(
|
|
api_key=None, base_url="http://localhost:1234/v1"
|
|
),
|
|
):
|
|
with patch("openai.OpenAI", return_value=fake_client):
|
|
models = OpenAIEmbeddingsProvider.get_available_models()
|
|
|
|
assert [m["value"] for m in models] == ["nomic-embed-text-v1.5"]
|
|
|
|
def test_lmstudio_returns_every_loaded_model_unfiltered(self):
|
|
"""Every loaded model — embedding or LLM, well-known name or
|
|
custom fine-tune — is returned. We don't pre-filter the list."""
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
ids = [
|
|
"llama-3.1-8b-instruct",
|
|
"nomic-embed-text-v1.5",
|
|
"qwen3-embedding-8b",
|
|
"bge-large-en-v1.5",
|
|
"some-custom-finetune",
|
|
]
|
|
fake_client = MagicMock()
|
|
fake_client.models.list.return_value = self._fake_models_response(ids)
|
|
|
|
with patch(
|
|
"local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
|
|
side_effect=self._settings_mock(
|
|
api_key=None, base_url="http://localhost:1234/v1"
|
|
),
|
|
):
|
|
with patch("openai.OpenAI", return_value=fake_client):
|
|
models = OpenAIEmbeddingsProvider.get_available_models()
|
|
|
|
# Order preserved, nothing dropped, no name-based flags added.
|
|
assert [m["value"] for m in models] == ids
|
|
assert all("is_embedding" not in m for m in models)
|
|
|
|
|
|
class TestOpenAIEmbeddingsSettingsRegistration:
|
|
"""The settings file shipped for issue #3883 must register the
|
|
keys the UI needs to surface the embeddings form, and the OpenAI
|
|
provider must be wired up by ``_get_provider_classes`` regardless
|
|
of API-key presence so it can be selected for keyless local
|
|
servers."""
|
|
|
|
def test_openai_embeddings_settings_file_registers_all_keys(self):
|
|
"""``settings_openai_embeddings.json`` must declare the four
|
|
``embeddings.openai.*`` keys that the provider reads at
|
|
runtime (api_key, base_url, model, dimensions)."""
|
|
import json
|
|
from pathlib import Path
|
|
import local_deep_research.defaults as defaults_pkg
|
|
|
|
defaults_dir = Path(defaults_pkg.__file__).parent
|
|
path = defaults_dir / "settings_openai_embeddings.json"
|
|
assert path.exists(), (
|
|
"Issue #3883: ship settings_openai_embeddings.json so the "
|
|
"UI can surface the embeddings configuration form."
|
|
)
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
|
|
required = {
|
|
"embeddings.openai.api_key",
|
|
"embeddings.openai.base_url",
|
|
"embeddings.openai.model",
|
|
"embeddings.openai.dimensions",
|
|
}
|
|
missing = required - set(data.keys())
|
|
assert not missing, f"settings file missing keys: {missing}"
|
|
|
|
base_url_meta = data["embeddings.openai.base_url"]
|
|
assert base_url_meta["category"] == "embeddings"
|
|
assert base_url_meta["editable"] is True
|
|
# The description is what tells users *why* this matters for
|
|
# local servers — guard against silent removal.
|
|
desc_lower = base_url_meta["description"].lower()
|
|
assert any(
|
|
tag in desc_lower for tag in ("lm studio", "vllm", "llama.cpp")
|
|
), "base_url description should mention an OpenAI-compatible server"
|
|
|
|
def test_openai_in_provider_classes_dict(self):
|
|
"""The 'openai' key must remain wired to the provider class so
|
|
the embeddings dispatch path can resolve it."""
|
|
from local_deep_research.embeddings.embeddings_config import (
|
|
_get_provider_classes,
|
|
)
|
|
from local_deep_research.embeddings.providers.implementations.openai import (
|
|
OpenAIEmbeddingsProvider,
|
|
)
|
|
|
|
classes = _get_provider_classes()
|
|
assert classes.get("openai") is OpenAIEmbeddingsProvider
|