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

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