Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

551 lines
21 KiB
Python

"""
Tests for API key configuration and LLM execution.
Tests that API keys can be set and used properly for different LLM providers.
"""
import pytest
from unittest.mock import Mock, patch
from langchain_core.language_models import BaseChatModel
from local_deep_research.config.llm_config import (
get_llm,
)
from local_deep_research.llm.providers.implementations.openai import (
OpenAIProvider,
)
from local_deep_research.llm.providers.implementations.anthropic import (
AnthropicProvider,
)
from local_deep_research.llm.providers.implementations.custom_openai_endpoint import (
CustomOpenAIEndpointProvider,
)
from local_deep_research.settings import SettingsManager
def _llm_mock(**kwargs):
"""Build a Mock that satisfies the BaseChatModel isinstance check.
The registered-LLM branch in get_llm() validates that create_llm()
returned a real BaseChatModel; with spec=BaseChatModel the Mock
passes isinstance() while still capturing call args.
"""
return Mock(spec=BaseChatModel, **kwargs)
@pytest.fixture(autouse=True)
def _restore_auto_discovered_providers():
"""Make sure auto-discovered providers are registered before each test.
The previous version of this fixture cleared the registry to force the
procedural ``if/elif`` chain in ``get_llm`` — that chain has been
deleted, so tests now go through the live registered-LLM path. ChatXxx
patches in each test target the provider class module (e.g.
``...implementations.openai.ChatOpenAI``) which is where the LLM is
actually constructed.
``force_refresh=True`` is required because the discovery singleton
short-circuits subsequent calls otherwise — and other test modules in
the suite may have called ``clear_llm_registry()`` between tests.
"""
from local_deep_research.llm.providers import discover_providers
discover_providers(force_refresh=True)
yield
class TestAPIKeyConfiguration:
"""Test API key configuration for different providers."""
@pytest.fixture
def mock_db_session(self):
"""Create a mock database session."""
session = Mock()
return session
@pytest.fixture
def settings_manager(self, mock_db_session):
"""Create a settings manager with mock session."""
return SettingsManager(mock_db_session)
def _get_base_settings(self):
"""Get base settings that all tests need."""
return {
"search.tool": "searxng",
"llm.supports_max_tokens": True,
"llm.max_tokens": 4096,
"llm.context_window_unrestricted": False,
"llm.context_window_size": 8192,
"llm.local_context_window_size": 4096,
"rate_limiting.llm_enabled": False,
}
@pytest.fixture
def settings_snapshot_with_openai(self):
"""Create a settings snapshot with OpenAI API key."""
settings = self._get_base_settings()
settings.update(
{
"llm.provider": "openai",
"llm.model": "gpt-4",
"llm.temperature": 0.7,
"llm.openai.api_key": "test-openai-api-key",
}
)
return settings
@pytest.fixture
def settings_snapshot_with_anthropic(self):
"""Create a settings snapshot with Anthropic API key."""
settings = self._get_base_settings()
settings.update(
{
"llm.provider": "anthropic",
"llm.model": "claude-3-opus-20240229",
"llm.temperature": 0.7,
"llm.anthropic.api_key": "test-anthropic-api-key",
"llm.context_window_size": 200000,
}
)
return settings
@pytest.fixture
def settings_snapshot_with_openai_endpoint(self):
"""Create a settings snapshot with OpenAI endpoint API key."""
settings = self._get_base_settings()
settings.update(
{
"llm.provider": "openai_endpoint",
"llm.model": "claude-3-opus",
"llm.temperature": 0.5,
"llm.openai_endpoint.api_key": "test-openrouter-api-key",
"llm.openai_endpoint.url": "https://openrouter.ai/api/v1",
"llm.context_window_unrestricted": True,
"llm.context_window_size": 128000,
# "library" is the only engine the factory can instantiate
# from this minimal snapshot (searxng et al. need an instance
# URL), and the research path does create the engine. But
# "library" is a PRIVATE engine, so under the default adaptive
# egress scope it would resolve to PRIVATE_ONLY and force
# local LLM — denying the remote openai_endpoint provider this
# test configures. Pin the scope to "unprotected" (the escape
# hatch; "both" is retired per ADR-0007) so the test stays
# about LLM-provider config, not egress.
"search.tool": "library",
"policy.egress_scope": "unprotected",
"search.max_results": 10,
"search.cross_engine_max_results": 100,
"search.cross_engine_use_reddit": False,
"search.cross_engine_min_date": None,
"search.region": "us",
"search.time_period": "y",
"search.safe_search": True,
"search.snippets_only": True,
"search.search_language": "English",
"search.max_filtered_results": 20,
"research.iterations": 2,
"research.questions_per_iteration": 3,
"research.local_context": 2000,
"research.web_context": 2000,
}
)
return settings
def test_openai_api_key_configuration(self, settings_snapshot_with_openai):
"""Test that OpenAI API key can be configured and used."""
with patch(
"local_deep_research.llm.providers.implementations.openai.ChatOpenAI"
) as mock_openai:
# Create a mock LLM instance
mock_llm_instance = _llm_mock()
mock_openai.return_value = mock_llm_instance
# Get LLM with OpenAI settings
get_llm(settings_snapshot=settings_snapshot_with_openai)
# Verify ChatOpenAI was called with correct parameters
mock_openai.assert_called_once()
call_args = mock_openai.call_args
assert call_args.kwargs["model"] == "gpt-4"
assert call_args.kwargs["api_key"] == "test-openai-api-key"
assert call_args.kwargs["temperature"] == 0.7
assert "max_tokens" in call_args.kwargs
def test_anthropic_api_key_configuration(
self, settings_snapshot_with_anthropic
):
"""Test that Anthropic API key can be configured and used."""
with patch(
"local_deep_research.llm.providers.implementations.anthropic.ChatAnthropic"
) as mock_anthropic:
# Create a mock LLM instance
mock_llm_instance = _llm_mock()
mock_anthropic.return_value = mock_llm_instance
# Get LLM with Anthropic settings
get_llm(settings_snapshot=settings_snapshot_with_anthropic)
# Verify ChatAnthropic was called with correct parameters
mock_anthropic.assert_called_once()
call_args = mock_anthropic.call_args
assert call_args.kwargs["model"] == "claude-3-opus-20240229"
assert (
call_args.kwargs["anthropic_api_key"]
== "test-anthropic-api-key"
)
assert call_args.kwargs["temperature"] == 0.7
assert "max_tokens" in call_args.kwargs
def test_openai_endpoint_configuration(
self, settings_snapshot_with_openai_endpoint
):
"""Test that OpenAI endpoint (OpenRouter) API key can be configured and used."""
# Patch the openai_base SSRF guard to a passthrough so this config
# test does not depend on live DNS resolution of the endpoint host
# (matches tests/llm/test_provider_base_url_ssrf.py convention).
with (
patch(
"local_deep_research.llm.providers.openai_base.ChatOpenAI"
) as mock_openai,
patch(
"local_deep_research.llm.providers.openai_base.assert_base_url_safe",
side_effect=lambda url, **_kwargs: url,
),
):
# Create a mock LLM instance
mock_llm_instance = _llm_mock()
mock_openai.return_value = mock_llm_instance
# Get LLM with OpenAI endpoint settings
get_llm(settings_snapshot=settings_snapshot_with_openai_endpoint)
# Verify ChatOpenAI was called with correct parameters for endpoint
mock_openai.assert_called_once()
call_args = mock_openai.call_args
assert call_args.kwargs["model"] == "claude-3-opus"
assert call_args.kwargs["api_key"] == "test-openrouter-api-key"
assert (
call_args.kwargs["base_url"] == "https://openrouter.ai/api/v1"
)
assert call_args.kwargs["temperature"] == 0.5
def test_llm_execution_with_api_key(self, settings_snapshot_with_openai):
"""Test that LLM can actually be invoked with API key."""
with patch(
"local_deep_research.llm.providers.implementations.openai.ChatOpenAI"
) as mock_openai:
# Create a mock LLM instance with invoke method
mock_llm_instance = _llm_mock()
mock_response = Mock()
mock_response.content = "Test response from OpenAI"
mock_llm_instance.invoke.return_value = mock_response
mock_openai.return_value = mock_llm_instance
# Get LLM and test invocation
llm = get_llm(settings_snapshot=settings_snapshot_with_openai)
response = llm.invoke("Test prompt")
# Verify the LLM was invoked
mock_llm_instance.invoke.assert_called_once_with("Test prompt")
assert "Test response from OpenAI" in response.content
def test_multiple_provider_switching(
self,
settings_snapshot_with_openai,
settings_snapshot_with_anthropic,
settings_snapshot_with_openai_endpoint,
):
"""Test switching between different providers with their API keys."""
# Test OpenAI
with patch(
"local_deep_research.llm.providers.implementations.openai.ChatOpenAI"
) as mock_openai:
mock_openai.return_value = _llm_mock()
get_llm(settings_snapshot=settings_snapshot_with_openai)
assert mock_openai.called
assert (
mock_openai.call_args.kwargs["api_key"] == "test-openai-api-key"
)
# Test Anthropic
with patch(
"local_deep_research.llm.providers.implementations.anthropic.ChatAnthropic"
) as mock_anthropic:
mock_anthropic.return_value = _llm_mock()
get_llm(settings_snapshot=settings_snapshot_with_anthropic)
assert mock_anthropic.called
assert (
mock_anthropic.call_args.kwargs["anthropic_api_key"]
== "test-anthropic-api-key"
)
# Test OpenAI Endpoint
# Patch the openai_base SSRF guard to a passthrough so this config
# test does not depend on live DNS resolution of the endpoint host.
with (
patch(
"local_deep_research.llm.providers.openai_base.ChatOpenAI"
) as mock_openai_endpoint,
patch(
"local_deep_research.llm.providers.openai_base.assert_base_url_safe",
side_effect=lambda url, **_kwargs: url,
),
):
mock_openai_endpoint.return_value = _llm_mock()
get_llm(settings_snapshot=settings_snapshot_with_openai_endpoint)
assert mock_openai_endpoint.called
assert (
mock_openai_endpoint.call_args.kwargs["api_key"]
== "test-openrouter-api-key"
)
assert (
mock_openai_endpoint.call_args.kwargs["base_url"]
== "https://openrouter.ai/api/v1"
)
def test_research_with_api_configured_llm(
self, settings_snapshot_with_openai_endpoint
):
"""Test that research can use LLM with configured API key."""
from local_deep_research.api.research_functions import quick_summary
# Patch the openai_base SSRF guard to a passthrough so this config
# test does not depend on live DNS resolution of the endpoint host.
with (
patch(
"local_deep_research.llm.providers.openai_base.ChatOpenAI"
) as mock_openai,
patch(
"local_deep_research.llm.providers.openai_base.assert_base_url_safe",
side_effect=lambda url, **_kwargs: url,
),
):
# Setup mock LLM
mock_llm_instance = _llm_mock()
mock_response = Mock()
mock_response.content = "Research summary about test topic"
mock_llm_instance.invoke.return_value = mock_response
mock_openai.return_value = mock_llm_instance
# Mock the search system to avoid network calls
with patch(
"local_deep_research.api.research_functions.AdvancedSearchSystem"
) as mock_search_system:
mock_system_instance = Mock()
mock_system_instance.analyze_topic.return_value = {
"current_knowledge": "Research summary about test topic",
"sources": ["https://example.com/test-topic"],
"all_links_of_system": ["https://example.com/test-topic"],
}
mock_search_system.return_value = mock_system_instance
# Run research with API-configured LLM
quick_summary(
query="Test research query",
settings_snapshot=settings_snapshot_with_openai_endpoint,
)
# Verify LLM was created with API key
assert mock_openai.called
assert (
mock_openai.call_args.kwargs["api_key"]
== "test-openrouter-api-key"
)
def test_api_availability_checks(self):
"""Test the availability check functions for different providers."""
# Test OpenAI availability with settings snapshot
settings_with_openai = {
"llm.openai.api_key": {"value": "test-openai-key", "type": "str"}
}
assert (
OpenAIProvider.is_available(settings_snapshot=settings_with_openai)
is True
)
settings_without_openai = {
"llm.openai.api_key": {"value": None, "type": "str"}
}
assert (
OpenAIProvider.is_available(
settings_snapshot=settings_without_openai
)
is False
)
# Test Anthropic availability
settings_with_anthropic = {
"llm.anthropic.api_key": {
"value": "test-anthropic-key",
"type": "str",
}
}
assert (
AnthropicProvider.is_available(
settings_snapshot=settings_with_anthropic
)
is True
)
settings_without_anthropic = {
"llm.anthropic.api_key": {"value": None, "type": "str"}
}
assert (
AnthropicProvider.is_available(
settings_snapshot=settings_without_anthropic
)
is False
)
# Test OpenAI endpoint availability
settings_with_endpoint = {
"llm.openai_endpoint.api_key": {
"value": "test-endpoint-key",
"type": "str",
}
}
assert (
CustomOpenAIEndpointProvider.is_available(
settings_snapshot=settings_with_endpoint
)
is True
)
settings_without_endpoint = {
"llm.openai_endpoint.api_key": {"value": None, "type": "str"}
}
assert (
CustomOpenAIEndpointProvider.is_available(
settings_snapshot=settings_without_endpoint
)
is False
)
class TestLLMIntegration:
"""Integration tests for LLM execution with real-like scenarios."""
def _get_base_settings(self):
"""Get base settings that all tests need."""
return {
"search.tool": "searxng",
"llm.supports_max_tokens": True,
"llm.max_tokens": 4096,
"llm.context_window_unrestricted": False,
"llm.context_window_size": 8192,
"llm.local_context_window_size": 4096,
"rate_limiting.llm_enabled": False,
}
def test_llm_with_token_counting(self):
"""Test LLM execution with token counting enabled."""
settings_snapshot = self._get_base_settings()
settings_snapshot.update(
{
"llm.provider": "openai",
"llm.model": "gpt-4",
"llm.temperature": 0.7,
"llm.openai.api_key": "test-openai-api-key",
}
)
with patch(
"local_deep_research.llm.providers.implementations.openai.ChatOpenAI"
) as mock_openai:
# Setup mock LLM with callbacks
mock_llm_instance = _llm_mock()
mock_llm_instance.callbacks = []
mock_response = Mock()
mock_response.content = "Test response"
mock_llm_instance.invoke.return_value = mock_response
mock_openai.return_value = mock_llm_instance
# Get LLM with research_id for token counting
get_llm(
settings_snapshot=settings_snapshot,
research_id="test-research-123",
research_context={"phase": "testing"},
)
# Verify LLM was created with research_id
assert mock_openai.called
def test_llm_error_handling(self):
"""Test LLM error handling when API calls fail."""
settings_snapshot = self._get_base_settings()
settings_snapshot.update(
{
"llm.provider": "openai",
"llm.model": "gpt-4",
"llm.temperature": 0.7,
"llm.openai.api_key": "test-openai-api-key",
}
)
with patch(
"local_deep_research.llm.providers.implementations.openai.ChatOpenAI"
) as mock_openai:
# Setup mock LLM that raises error
mock_llm_instance = _llm_mock()
mock_llm_instance.invoke.side_effect = Exception(
"API rate limit exceeded"
)
mock_openai.return_value = mock_llm_instance
# Get LLM and test error handling
llm = get_llm(settings_snapshot=settings_snapshot)
with pytest.raises(Exception) as exc_info:
llm.invoke("Test prompt")
assert "API rate limit exceeded" in str(exc_info.value)
def test_custom_endpoint_url_configuration(self):
"""Test configuring custom endpoint URLs for different providers."""
settings_snapshot = self._get_base_settings()
settings_snapshot.update(
{
"llm.provider": "openai_endpoint",
"llm.model": "custom-model",
"llm.temperature": 0.7,
"llm.openai_endpoint.api_key": "test-key",
"llm.openai_endpoint.url": "https://custom-llm-provider.com/v1",
"llm.max_tokens": 2048,
}
)
# This test verifies config passthrough (the configured URL/api_key
# reach the ChatOpenAI constructor), not SSRF enforcement. The
# ``custom-llm-provider.com`` placeholder host does not resolve, so
# the openai_base SSRF guard (assert_base_url_safe) would fail-closed
# on its live DNS lookup. Patch the guard to a passthrough — the same
# convention as tests/llm/test_provider_base_url_ssrf.py — so the
# passthrough assertions stay deterministic and offline. SSRF
# enforcement itself is covered by test_provider_base_url_ssrf.py.
with (
patch(
"local_deep_research.llm.providers.openai_base.ChatOpenAI"
) as mock_openai,
patch(
"local_deep_research.llm.providers.openai_base.assert_base_url_safe",
side_effect=lambda url, **_kwargs: url,
),
):
mock_openai.return_value = _llm_mock()
get_llm(settings_snapshot=settings_snapshot)
# Verify custom URL was used
assert (
mock_openai.call_args.kwargs["base_url"]
== "https://custom-llm-provider.com/v1"
)
assert mock_openai.call_args.kwargs["api_key"] == "test-key"
if __name__ == "__main__":
pytest.main([__file__, "-v"])