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
333 lines
13 KiB
Python
333 lines
13 KiB
Python
"""High-value edge case tests for LM Studio, OpenRouter, xAI, and IONOS providers.
|
|
|
|
Focuses on error handling paths, cross-provider consistency, edge cases in
|
|
configuration, list_models behavior, and provider-specific quirks.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from local_deep_research.llm.providers.implementations.lmstudio import (
|
|
LMStudioProvider,
|
|
)
|
|
from local_deep_research.llm.providers.implementations.openrouter import (
|
|
OpenRouterProvider,
|
|
)
|
|
from local_deep_research.llm.providers.implementations.xai import (
|
|
XAIProvider,
|
|
)
|
|
from local_deep_research.llm.providers.implementations.ionos import (
|
|
IONOSProvider,
|
|
)
|
|
from local_deep_research.llm.providers.openai_base import (
|
|
OpenAICompatibleProvider,
|
|
)
|
|
from local_deep_research.llm.providers.base import BaseLLMProvider
|
|
|
|
|
|
def _lmstudio_settings(url_value, extras=None):
|
|
"""Side-effect factory for LM Studio (no API key, URL-based)."""
|
|
base = {
|
|
"llm.lmstudio.url": url_value,
|
|
"llm.max_tokens": None,
|
|
}
|
|
if extras:
|
|
base.update(extras)
|
|
|
|
def _side_effect(*args, **kwargs):
|
|
key = args[0] if args else kwargs.get("key", "")
|
|
default = args[1] if len(args) > 1 else kwargs.get("default", None)
|
|
return base.get(key, default)
|
|
|
|
return _side_effect
|
|
|
|
|
|
def _cloud_settings(api_key_setting, api_key_value, extras=None):
|
|
"""Build a mock side-effect for cloud provider settings."""
|
|
base = {
|
|
api_key_setting: api_key_value,
|
|
"llm.max_tokens": None,
|
|
"llm.streaming": None,
|
|
"llm.max_retries": None,
|
|
"llm.request_timeout": None,
|
|
}
|
|
if extras:
|
|
base.update(extras)
|
|
|
|
def _side_effect(key, default=None, *args, **kwargs):
|
|
return base.get(key, default)
|
|
|
|
return _side_effect
|
|
|
|
|
|
# ===========================================================================
|
|
# LM Studio edge cases
|
|
# ===========================================================================
|
|
|
|
|
|
class TestLMStudioEdgeCases:
|
|
"""Edge cases unique to the LM Studio provider."""
|
|
|
|
def test_is_available_with_non_200_status_codes(self):
|
|
"""Various non-200 codes return False."""
|
|
for status in [301, 403, 404, 502, 503]:
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _lmstudio_settings(
|
|
"http://localhost:1234/v1"
|
|
)
|
|
with patch("local_deep_research.security.safe_get") as mock_get:
|
|
mock_response = Mock(status_code=status)
|
|
mock_get.return_value = mock_response
|
|
assert LMStudioProvider.is_available() is False, (
|
|
f"Expected False for HTTP {status}"
|
|
)
|
|
|
|
def test_is_available_timeout_returns_false(self):
|
|
"""A connection timeout is handled gracefully."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _lmstudio_settings("http://localhost:1234/v1")
|
|
with patch("local_deep_research.security.safe_get") as mock_get:
|
|
mock_get.side_effect = TimeoutError("Connection timed out")
|
|
assert LMStudioProvider.is_available() is False
|
|
|
|
def test_lmstudio_inherits_from_openai_compatible(self):
|
|
"""LM Studio provider inherits from OpenAICompatibleProvider."""
|
|
assert issubclass(LMStudioProvider, OpenAICompatibleProvider)
|
|
assert issubclass(LMStudioProvider, BaseLLMProvider)
|
|
|
|
|
|
# ===========================================================================
|
|
# OpenRouter edge cases
|
|
# ===========================================================================
|
|
|
|
|
|
class TestOpenRouterEdgeCases:
|
|
"""Edge cases unique to the OpenRouter provider."""
|
|
|
|
def test_create_llm_with_whitespace_only_api_key(self):
|
|
"""Whitespace-only API key is normalized as missing and raises (required cloud provider)."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings(
|
|
"llm.openrouter.api_key", " "
|
|
)
|
|
with pytest.raises(ValueError, match="API key not configured"):
|
|
OpenRouterProvider.create_llm(model_name="test-model")
|
|
|
|
def test_create_llm_error_message_contains_setting_name(self):
|
|
"""Error message tells user which setting to configure."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings(
|
|
"llm.openrouter.api_key", None
|
|
)
|
|
with pytest.raises(ValueError) as exc_info:
|
|
OpenRouterProvider.create_llm()
|
|
assert "llm.openrouter.api_key" in str(exc_info.value)
|
|
|
|
def test_list_models_for_api_connection_error_returns_empty(self):
|
|
"""Network error returns empty list, not exception."""
|
|
with patch("openai.OpenAI") as mock_openai:
|
|
mock_openai.side_effect = ConnectionError("Network unreachable")
|
|
result = OpenRouterProvider.list_models_for_api(api_key="key")
|
|
assert result == []
|
|
|
|
def test_openrouter_inherits_from_openai_compatible(self):
|
|
"""OpenRouter provider inherits from OpenAICompatibleProvider."""
|
|
assert issubclass(OpenRouterProvider, OpenAICompatibleProvider)
|
|
assert issubclass(OpenRouterProvider, BaseLLMProvider)
|
|
|
|
def test_create_llm_passes_max_tokens_when_configured(self):
|
|
"""max_tokens from settings is forwarded to ChatOpenAI."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings(
|
|
"llm.openrouter.api_key",
|
|
"test-key",
|
|
extras={"llm.max_tokens": 2048},
|
|
)
|
|
with patch(
|
|
"local_deep_research.llm.providers.openai_base.ChatOpenAI"
|
|
) as mock_chat:
|
|
mock_chat.return_value = Mock()
|
|
OpenRouterProvider.create_llm(model_name="test-model")
|
|
call_kwargs = mock_chat.call_args[1]
|
|
assert call_kwargs["max_tokens"] == 2048
|
|
|
|
|
|
# ===========================================================================
|
|
# xAI edge cases
|
|
# ===========================================================================
|
|
|
|
|
|
class TestXAIEdgeCases:
|
|
"""Edge cases unique to the xAI Grok provider."""
|
|
|
|
def test_create_llm_with_empty_string_api_key(self):
|
|
"""Empty string API key raises ValueError."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings("llm.xai.api_key", "")
|
|
with pytest.raises(ValueError, match="(?i)api key"):
|
|
XAIProvider.create_llm()
|
|
|
|
def test_create_llm_error_message_contains_xai_setting(self):
|
|
"""Error message references the correct xAI setting key."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings("llm.xai.api_key", None)
|
|
with pytest.raises(ValueError) as exc_info:
|
|
XAIProvider.create_llm()
|
|
assert "llm.xai.api_key" in str(exc_info.value)
|
|
|
|
def test_list_models_for_api_requires_key(self):
|
|
"""xAI requires auth so list_models_for_api returns [] without key."""
|
|
result = XAIProvider.list_models_for_api(api_key=None)
|
|
assert result == []
|
|
|
|
def test_is_available_with_exception_in_settings(self):
|
|
"""Exception during settings retrieval returns False."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = RuntimeError("settings DB corrupted")
|
|
assert XAIProvider.is_available() is False
|
|
|
|
def test_xai_inherits_from_openai_compatible(self):
|
|
"""xAI provider inherits from OpenAICompatibleProvider."""
|
|
assert issubclass(XAIProvider, OpenAICompatibleProvider)
|
|
assert issubclass(XAIProvider, BaseLLMProvider)
|
|
|
|
|
|
# ===========================================================================
|
|
# IONOS edge cases
|
|
# ===========================================================================
|
|
|
|
|
|
class TestIONOSEdgeCases:
|
|
"""Edge cases unique to the IONOS provider."""
|
|
|
|
def test_create_llm_with_none_api_key_raises(self):
|
|
"""Explicitly None API key raises ValueError."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings("llm.ionos.api_key", None)
|
|
with pytest.raises(ValueError, match="(?i)api key"):
|
|
IONOSProvider.create_llm()
|
|
|
|
def test_create_llm_error_message_references_ionos_setting(self):
|
|
"""Error message tells the user exactly which setting is missing."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.side_effect = _cloud_settings("llm.ionos.api_key", None)
|
|
with pytest.raises(ValueError) as exc_info:
|
|
IONOSProvider.create_llm()
|
|
assert "llm.ionos.api_key" in str(exc_info.value)
|
|
|
|
def test_list_models_for_api_without_key_returns_empty(self):
|
|
"""IONOS requires auth so returns [] without a key."""
|
|
result = IONOSProvider.list_models_for_api(api_key=None)
|
|
assert result == []
|
|
|
|
def test_is_available_with_whitespace_only_key(self):
|
|
"""Whitespace-only API key is treated as unavailable."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot"
|
|
) as mock_gs:
|
|
mock_gs.return_value = " "
|
|
assert IONOSProvider.is_available() is False
|
|
|
|
def test_ionos_inherits_from_openai_compatible(self):
|
|
"""IONOS provider inherits from OpenAICompatibleProvider."""
|
|
assert issubclass(IONOSProvider, OpenAICompatibleProvider)
|
|
assert issubclass(IONOSProvider, BaseLLMProvider)
|
|
|
|
|
|
# ===========================================================================
|
|
# Cross-provider consistency tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestCrossProviderConsistency:
|
|
"""Verify consistent behaviour across all four providers."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_cls,expected_key",
|
|
[
|
|
(LMStudioProvider, "LMSTUDIO"),
|
|
(OpenRouterProvider, "OPENROUTER"),
|
|
(XAIProvider, "XAI"),
|
|
(IONOSProvider, "IONOS"),
|
|
],
|
|
)
|
|
def test_provider_key_is_uppercase(self, provider_cls, expected_key):
|
|
"""All provider_key values should be uppercase identifiers."""
|
|
assert provider_cls.provider_key == expected_key
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_cls,is_cloud",
|
|
[
|
|
(LMStudioProvider, False),
|
|
(OpenRouterProvider, True),
|
|
(XAIProvider, True),
|
|
(IONOSProvider, True),
|
|
],
|
|
)
|
|
def test_cloud_flag_consistency(self, provider_cls, is_cloud):
|
|
"""Cloud flag matches expected value for each provider."""
|
|
assert provider_cls.is_cloud is is_cloud
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_cls",
|
|
[LMStudioProvider, OpenRouterProvider, XAIProvider, IONOSProvider],
|
|
)
|
|
def test_default_base_url_is_valid(self, provider_cls):
|
|
"""All providers must have a non-empty default_base_url with a scheme."""
|
|
url = provider_cls.default_base_url
|
|
assert url is not None
|
|
assert url.startswith("http://") or url.startswith("https://")
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_cls",
|
|
[LMStudioProvider, OpenRouterProvider, XAIProvider, IONOSProvider],
|
|
)
|
|
def test_default_model_is_empty(self, provider_cls):
|
|
"""Every provider must declare an empty default model — users must
|
|
explicitly pick one. No silent fallbacks."""
|
|
assert provider_cls.default_model == ""
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_cls",
|
|
[OpenRouterProvider, XAIProvider, IONOSProvider],
|
|
)
|
|
def test_cloud_providers_require_api_key_setting(self, provider_cls):
|
|
"""Cloud providers must define an api_key_setting."""
|
|
assert provider_cls.api_key_setting is not None
|
|
assert provider_cls.api_key_setting.startswith("llm.")
|
|
|
|
def test_local_provider_does_not_require_api_key(self):
|
|
"""LM Studio (local) declares its setting key but treats it as optional."""
|
|
assert LMStudioProvider.api_key_setting == "llm.lmstudio.api_key"
|
|
assert LMStudioProvider.api_key_optional is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_cls",
|
|
[LMStudioProvider, OpenRouterProvider, XAIProvider, IONOSProvider],
|
|
)
|
|
def test_all_providers_have_company_name(self, provider_cls):
|
|
"""Every provider must have a company_name for UI display."""
|
|
assert provider_cls.company_name
|
|
assert isinstance(provider_cls.company_name, str)
|
|
assert len(provider_cls.company_name) > 0
|