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
505 lines
17 KiB
Python
505 lines
17 KiB
Python
"""
|
|
Comprehensive tests for provider availability functions and related helpers
|
|
in local_deep_research/config/llm_config.py.
|
|
|
|
Focuses on gaps not covered by existing test files:
|
|
- wrap_llm_without_think_tags() context_limit injection, token counter, string responses
|
|
- _get_context_window_for_provider() edge cases
|
|
- get_selected_llm_provider() with snapshot parameter
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MODULE = "local_deep_research.config.llm_config"
|
|
# _get_context_window_for_provider delegates to the _helpers twin, which reads
|
|
# settings via thread_settings.get_setting_from_snapshot (function-local
|
|
# import). Context-window assertions must therefore patch this module, not
|
|
# MODULE.
|
|
THREAD_SETTINGS = "local_deep_research.config.thread_settings"
|
|
|
|
|
|
# ===================================================================
|
|
# get_selected_llm_provider
|
|
# ===================================================================
|
|
|
|
|
|
class TestGetSelectedLlmProvider:
|
|
"""Additional coverage for get_selected_llm_provider()."""
|
|
|
|
def test_with_explicit_snapshot(self):
|
|
from local_deep_research.config.llm_config import (
|
|
get_selected_llm_provider,
|
|
)
|
|
|
|
result = get_selected_llm_provider(
|
|
settings_snapshot={"llm.provider": "Google"}
|
|
)
|
|
assert result == "google"
|
|
|
|
def test_mixed_case_normalised(self):
|
|
from local_deep_research.config.llm_config import (
|
|
get_selected_llm_provider,
|
|
)
|
|
|
|
result = get_selected_llm_provider(
|
|
settings_snapshot={"llm.provider": "OpenAI_Endpoint"}
|
|
)
|
|
assert result == "openai_endpoint"
|
|
|
|
def test_default_when_key_missing(self):
|
|
from local_deep_research.config.llm_config import (
|
|
get_selected_llm_provider,
|
|
)
|
|
|
|
result = get_selected_llm_provider(settings_snapshot={})
|
|
assert result == "ollama"
|
|
|
|
|
|
# ===================================================================
|
|
# _get_context_window_for_provider (additional edge cases)
|
|
# ===================================================================
|
|
|
|
|
|
class TestGetContextWindowForProvider:
|
|
"""Additional edge-case coverage for _get_context_window_for_provider."""
|
|
|
|
def test_openrouter_treated_as_cloud(self):
|
|
with patch(
|
|
f"{THREAD_SETTINGS}.get_setting_from_snapshot",
|
|
return_value=True,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
_get_context_window_for_provider,
|
|
)
|
|
|
|
assert _get_context_window_for_provider("openrouter") is None
|
|
|
|
def test_local_provider_with_float_value(self):
|
|
"""Float value from settings is coerced to int."""
|
|
with patch(
|
|
f"{THREAD_SETTINGS}.get_setting_from_snapshot",
|
|
return_value=8192.7,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
_get_context_window_for_provider,
|
|
)
|
|
|
|
result = _get_context_window_for_provider("lmstudio")
|
|
assert result == 8192
|
|
assert isinstance(result, int)
|
|
|
|
def test_cloud_restricted_with_float_value(self):
|
|
"""Float cloud window is coerced to int."""
|
|
call_num = [0]
|
|
|
|
def fake_setting(key, default, settings_snapshot=None):
|
|
call_num[0] += 1
|
|
if key == "llm.context_window_unrestricted":
|
|
return False
|
|
if key == "llm.context_window_size":
|
|
return 65536.9
|
|
return default
|
|
|
|
with patch(
|
|
f"{THREAD_SETTINGS}.get_setting_from_snapshot",
|
|
side_effect=fake_setting,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
_get_context_window_for_provider,
|
|
)
|
|
|
|
result = _get_context_window_for_provider("openai")
|
|
assert result == 65536
|
|
assert isinstance(result, int)
|
|
|
|
|
|
# ===================================================================
|
|
# wrap_llm_without_think_tags
|
|
# ===================================================================
|
|
|
|
|
|
class TestWrapLlmWithoutThinkTags:
|
|
"""Comprehensive tests for the ProcessingLLMWrapper created by wrap_llm_without_think_tags."""
|
|
|
|
def _make_wrapper(self, mock_llm, **kwargs):
|
|
"""Create wrapper with rate-limiting disabled."""
|
|
defaults = {
|
|
"research_id": None,
|
|
"provider": None,
|
|
"research_context": None,
|
|
"settings_snapshot": None,
|
|
}
|
|
defaults.update(kwargs)
|
|
with patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=False,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
return wrap_llm_without_think_tags(mock_llm, **defaults)
|
|
|
|
# --- basic wrapper behaviour ---
|
|
|
|
def test_wrapper_has_base_llm(self):
|
|
llm = MagicMock()
|
|
w = self._make_wrapper(llm)
|
|
assert w.base_llm is llm
|
|
|
|
def test_invoke_calls_base_llm(self):
|
|
llm = MagicMock()
|
|
resp = MagicMock()
|
|
resp.content = "hello"
|
|
llm.invoke.return_value = resp
|
|
w = self._make_wrapper(llm)
|
|
w.invoke("prompt")
|
|
llm.invoke.assert_called_once_with("prompt")
|
|
|
|
def test_think_tags_removed_from_content(self):
|
|
llm = MagicMock()
|
|
resp = MagicMock()
|
|
resp.content = "<think>internal reasoning</think>final answer"
|
|
llm.invoke.return_value = resp
|
|
w = self._make_wrapper(llm)
|
|
result = w.invoke("prompt")
|
|
assert "<think>" not in result.content
|
|
assert "final answer" in result.content
|
|
|
|
def test_think_tags_removed_from_string_response(self):
|
|
llm = MagicMock()
|
|
llm.invoke.return_value = "<think>thought</think>answer"
|
|
w = self._make_wrapper(llm)
|
|
result = w.invoke("prompt")
|
|
# A bare-string return is wrapped into a message so callers can rely on
|
|
# .content; think tags are still stripped.
|
|
assert not isinstance(result, str)
|
|
assert "<think>" not in result.content
|
|
assert "answer" in result.content
|
|
|
|
def test_response_without_content_attr_returned_as_is(self):
|
|
"""Response that is neither string nor has .content is passed through."""
|
|
llm = MagicMock()
|
|
resp = 42 # int has no .content
|
|
llm.invoke.return_value = resp
|
|
w = self._make_wrapper(llm)
|
|
result = w.invoke("prompt")
|
|
assert result == 42
|
|
|
|
def test_string_response_wrapped_in_message(self):
|
|
"""A bare-string return is wrapped into an AIMessage with .content."""
|
|
from langchain_core.messages import AIMessage
|
|
|
|
llm = MagicMock()
|
|
llm.invoke.return_value = "<think>t</think>final"
|
|
w = self._make_wrapper(llm)
|
|
result = w.invoke("prompt")
|
|
assert isinstance(result, AIMessage)
|
|
assert result.content == "final"
|
|
|
|
def test_preserves_reasoning_content_and_tool_calls(self):
|
|
"""Stripping <think> from .content must NOT drop reasoning_content/tool_calls.
|
|
|
|
Guards against worsening DeepSeek thinking-mode round-tripping (#4194):
|
|
we only rewrite .content in place, leaving the rest of the message intact.
|
|
"""
|
|
from langchain_core.messages import AIMessage
|
|
|
|
llm = MagicMock()
|
|
llm.invoke.return_value = AIMessage(
|
|
content="<think>reasoning</think>answer",
|
|
additional_kwargs={"reasoning_content": "R"},
|
|
tool_calls=[
|
|
{"name": "search", "args": {}, "id": "1", "type": "tool_call"}
|
|
],
|
|
)
|
|
w = self._make_wrapper(llm)
|
|
result = w.invoke("prompt")
|
|
assert result.content == "answer"
|
|
assert result.additional_kwargs["reasoning_content"] == "R"
|
|
assert result.tool_calls and result.tool_calls[0]["name"] == "search"
|
|
|
|
def test_ainvoke_normalizes_string_response(self):
|
|
"""ainvoke applies the same normalization as invoke (str -> message)."""
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
llm = MagicMock()
|
|
llm.ainvoke = AsyncMock(return_value="<think>t</think>async answer")
|
|
w = self._make_wrapper(llm)
|
|
result = asyncio.run(w.ainvoke("prompt"))
|
|
assert not isinstance(result, str)
|
|
assert result.content == "async answer"
|
|
|
|
def test_invoke_exception_propagated(self):
|
|
llm = MagicMock()
|
|
llm.invoke.side_effect = ConnectionError("timeout")
|
|
w = self._make_wrapper(llm)
|
|
with pytest.raises(ConnectionError, match="timeout"):
|
|
w.invoke("prompt")
|
|
|
|
# --- __getattr__ delegation ---
|
|
|
|
def test_getattr_delegates_to_base_llm(self):
|
|
llm = MagicMock()
|
|
llm.model_name = "gpt-4"
|
|
llm.some_custom_attr = "custom_value"
|
|
w = self._make_wrapper(llm)
|
|
assert w.model_name == "gpt-4"
|
|
assert w.some_custom_attr == "custom_value"
|
|
|
|
# --- context_limit injection ---
|
|
|
|
def test_context_limit_set_in_research_context(self):
|
|
"""wrap_llm sets context_limit in research_context when provider is local."""
|
|
llm = MagicMock()
|
|
research_ctx = {}
|
|
|
|
def fake_setting(key, default=None, settings_snapshot=None):
|
|
if key == "rate_limiting.llm_enabled":
|
|
return False
|
|
if key == "llm.local_context_window_size":
|
|
return 4096
|
|
return default
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot", side_effect=fake_setting
|
|
),
|
|
patch(
|
|
f"{THREAD_SETTINGS}.get_setting_from_snapshot",
|
|
side_effect=fake_setting,
|
|
),
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(
|
|
llm, provider="ollama", research_context=research_ctx
|
|
)
|
|
assert research_ctx.get("context_limit") == 4096
|
|
|
|
def test_context_limit_not_overwritten_if_already_set(self):
|
|
"""If research_context already has context_limit, it should NOT be overwritten."""
|
|
llm = MagicMock()
|
|
research_ctx = {"context_limit": 9999}
|
|
|
|
def fake_setting(key, default=None, settings_snapshot=None):
|
|
if key == "rate_limiting.llm_enabled":
|
|
return False
|
|
if key == "llm.local_context_window_size":
|
|
return 4096
|
|
return default
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot", side_effect=fake_setting
|
|
),
|
|
patch(
|
|
f"{THREAD_SETTINGS}.get_setting_from_snapshot",
|
|
side_effect=fake_setting,
|
|
),
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(
|
|
llm, provider="ollama", research_context=research_ctx
|
|
)
|
|
assert research_ctx["context_limit"] == 9999
|
|
|
|
def test_context_limit_not_set_for_unrestricted_cloud(self):
|
|
"""Cloud unrestricted provider returns None window, so context_limit not set."""
|
|
llm = MagicMock()
|
|
research_ctx = {}
|
|
|
|
def fake_setting(key, default=None, settings_snapshot=None):
|
|
if key == "rate_limiting.llm_enabled":
|
|
return False
|
|
if key == "llm.context_window_unrestricted":
|
|
return True
|
|
return default
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot", side_effect=fake_setting
|
|
),
|
|
patch(
|
|
f"{THREAD_SETTINGS}.get_setting_from_snapshot",
|
|
side_effect=fake_setting,
|
|
),
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(
|
|
llm, provider="openai", research_context=research_ctx
|
|
)
|
|
assert "context_limit" not in research_ctx
|
|
|
|
def test_no_crash_when_research_context_is_none(self):
|
|
"""No crash when research_context=None."""
|
|
llm = MagicMock()
|
|
w = self._make_wrapper(llm, provider="openai", research_context=None)
|
|
assert w is not None
|
|
|
|
# --- rate limiting integration ---
|
|
|
|
def test_rate_limiting_applied_when_enabled(self):
|
|
llm = MagicMock()
|
|
rate_limited_llm = MagicMock()
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"local_deep_research.web_search_engines.rate_limiting.llm.create_rate_limited_llm_wrapper",
|
|
return_value=rate_limited_llm,
|
|
) as mock_rl,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrapper = wrap_llm_without_think_tags(llm, provider="openai")
|
|
mock_rl.assert_called_once_with(llm, "openai")
|
|
# The wrapper wraps the rate-limited LLM
|
|
assert wrapper.base_llm is rate_limited_llm
|
|
|
|
def test_rate_limiting_not_applied_when_disabled(self):
|
|
llm = MagicMock()
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"local_deep_research.web_search_engines.rate_limiting.llm.create_rate_limited_llm_wrapper",
|
|
) as mock_rl,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrapper = wrap_llm_without_think_tags(llm, provider="openai")
|
|
mock_rl.assert_not_called()
|
|
assert wrapper.base_llm is llm
|
|
|
|
# --- token counter callback ---
|
|
|
|
def test_token_counter_attached_when_research_id_given(self):
|
|
"""When research_id is set, a token counting callback is added."""
|
|
llm = MagicMock()
|
|
llm.callbacks = None
|
|
llm.model_name = "test-model"
|
|
|
|
mock_counter = MagicMock()
|
|
mock_callback = MagicMock()
|
|
mock_counter.create_callback.return_value = mock_callback
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"local_deep_research.metrics.TokenCounter",
|
|
return_value=mock_counter,
|
|
),
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(llm, research_id=42, provider="openai")
|
|
mock_counter.create_callback.assert_called_once_with(42, None)
|
|
assert mock_callback.preset_provider == "openai"
|
|
assert mock_callback.preset_model == "test-model"
|
|
|
|
def test_token_counter_uses_model_attr_fallback(self):
|
|
"""If llm has .model but not .model_name, uses .model."""
|
|
llm = MagicMock(spec=["invoke", "callbacks", "model"])
|
|
llm.callbacks = None
|
|
llm.model = "claude-3-opus"
|
|
|
|
mock_counter = MagicMock()
|
|
mock_callback = MagicMock()
|
|
mock_counter.create_callback.return_value = mock_callback
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"local_deep_research.metrics.TokenCounter",
|
|
return_value=mock_counter,
|
|
),
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(
|
|
llm, research_id=1, provider="anthropic"
|
|
)
|
|
assert mock_callback.preset_model == "claude-3-opus"
|
|
|
|
def test_callbacks_extended_when_existing(self):
|
|
"""If llm.callbacks already has entries, new callback is appended."""
|
|
existing_cb = MagicMock()
|
|
llm = MagicMock()
|
|
llm.callbacks = [existing_cb]
|
|
llm.model_name = "m"
|
|
|
|
mock_counter = MagicMock()
|
|
mock_callback = MagicMock()
|
|
mock_counter.create_callback.return_value = mock_callback
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"local_deep_research.metrics.TokenCounter",
|
|
return_value=mock_counter,
|
|
),
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(llm, research_id=10)
|
|
assert mock_callback in llm.callbacks
|
|
assert existing_cb in llm.callbacks
|
|
|
|
def test_no_callbacks_when_no_research_id(self):
|
|
"""When research_id is None, no callbacks are attached."""
|
|
llm = MagicMock()
|
|
llm.callbacks = None
|
|
|
|
with patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
return_value=False,
|
|
):
|
|
from local_deep_research.config.llm_config import (
|
|
wrap_llm_without_think_tags,
|
|
)
|
|
|
|
wrap_llm_without_think_tags(llm, research_id=None)
|
|
# callbacks should remain None (nothing to attach)
|
|
assert llm.callbacks is None
|