Files
learningcircuit--local-deep…/tests/config/test_llm_config.py
T
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

725 lines
29 KiB
Python

"""Tests for llm_config module."""
from unittest.mock import AsyncMock, MagicMock, patch
from local_deep_research.config.llm_config import (
get_selected_llm_provider,
wrap_llm_without_think_tags,
get_llm,
)
class TestGetSelectedLlmProvider:
"""Tests for get_selected_llm_provider function."""
def test_returns_provider_from_settings(self):
"""Should return provider from settings."""
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value="anthropic",
):
result = get_selected_llm_provider()
assert result == "anthropic"
def test_returns_lowercase(self):
"""Should return lowercase provider."""
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value="OPENAI",
):
result = get_selected_llm_provider()
assert result == "openai"
def test_defaults_to_ollama(self):
"""Should default to ollama."""
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value="ollama",
) as mock:
get_selected_llm_provider()
# Check default is ollama
mock.assert_called_with(
"llm.provider", "ollama", settings_snapshot=None
)
class TestWrapLlmWithoutThinkTags:
"""Tests for wrap_llm_without_think_tags function."""
def test_returns_wrapper_instance(self):
"""Should return a wrapper instance."""
mock_llm = MagicMock()
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
result = wrap_llm_without_think_tags(mock_llm)
assert hasattr(result, "invoke")
assert hasattr(result, "base_llm")
def test_wrapper_invoke_calls_base_llm(self):
"""Should call base LLM on invoke."""
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = "test response"
mock_llm.invoke.return_value = mock_response
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
wrapper.invoke("test prompt")
mock_llm.invoke.assert_called_with("test prompt")
def test_wrapper_removes_think_tags(self):
"""Should remove think tags from response."""
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = "<think>internal</think>visible"
mock_llm.invoke.return_value = mock_response
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.remove_think_tags",
return_value="visible",
) as mock_remove:
wrapper = wrap_llm_without_think_tags(mock_llm)
wrapper.invoke("test")
mock_remove.assert_called_with("<think>internal</think>visible")
def test_wrapper_preserves_nonstring_content(self):
"""Non-string content (e.g. provider content-block lists) must pass
through unchanged instead of raising TypeError in remove_think_tags."""
mock_llm = MagicMock()
mock_response = MagicMock()
blocks = [{"type": "text", "text": "visible"}]
mock_response.content = blocks
mock_llm.invoke.return_value = mock_response
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
result = wrapper.invoke("test")
assert result.content == blocks
def test_wrapper_preserves_none_content(self):
"""None content must pass through unchanged rather than raising."""
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = None
mock_llm.invoke.return_value = mock_response
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
result = wrapper.invoke("test")
assert result.content is None
def test_wrapper_delegates_attributes(self):
"""Should delegate attribute access to base LLM."""
mock_llm = MagicMock()
mock_llm.model_name = "gpt-4"
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
assert wrapper.model_name == "gpt-4"
def test_applies_rate_limiting_when_enabled(self):
"""Should apply rate limiting when enabled in settings."""
mock_llm = MagicMock()
mock_wrapped = MagicMock()
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=True,
):
# Patch at source location since it's imported inside the function
with patch(
"local_deep_research.web_search_engines.rate_limiting.llm.create_rate_limited_llm_wrapper",
return_value=mock_wrapped,
) as mock_create:
wrap_llm_without_think_tags(mock_llm, provider="openai")
mock_create.assert_called_with(mock_llm, "openai")
# -- bind_tools regression tests (issue #4804) -------------------------
#
# ``create_agent()`` calls ``model.bind_tools(tools)`` and uses the
# return value as the model that runs inside the agent loop. Without
# a ``bind_tools`` override on ``ProcessingLLMWrapper``, Python falls
# through ``__getattr__`` to ``self.base_llm.bind_tools(...)`` and the
# agent loop runs on an unwrapped ``BaseChatModel`` — which means
# ``_normalize_response`` never strips ``<think>…`` and reasoning-mode
# models (Qwen 3.x, deepseek-r1, etc.) leak raw ``<think>`` text into
# the conversation history. On the next LLM turn, strict
# OpenAI-compatible providers reject the request with
# ``Failed to parse input at pos 0: <think>…``. The override keeps
# the bound model wrapped, so the stripper stays in the loop.
def test_bind_tools_returns_wrapper_not_raw_base_llm(self):
"""``bind_tools`` must return a ``ProcessingLLMWrapper`` instance,
not the unwrapped bound model. Guards against future regressions
that reintroduce the bypass by deleting this override (issue
#4804)."""
mock_llm = MagicMock()
mock_bound = MagicMock()
mock_llm.bind_tools.return_value = mock_bound
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
bound_wrapper = wrapper.bind_tools([lambda: None])
assert hasattr(bound_wrapper, "base_llm")
assert bound_wrapper.base_llm is mock_bound
# The wrapped object must NOT be the raw base LLM (or its
# bound child) — it must be the wrapper that runs
# _normalize_response.
assert bound_wrapper is not mock_bound
assert bound_wrapper is not mock_llm
def test_bind_tools_forwards_tools_and_kwargs_to_base_llm(self):
"""Tools and provider-specific kwargs must be forwarded verbatim
to ``base_llm.bind_tools``."""
mock_llm = MagicMock()
mock_bound = MagicMock()
mock_llm.bind_tools.return_value = mock_bound
def sentinel_tool():
return None
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
wrapper.bind_tools(sentinel_tool, tool_choice="auto", parallel=True)
mock_llm.bind_tools.assert_called_once_with(
sentinel_tool, tool_choice="auto", parallel=True
)
def test_bound_wrapper_strips_think_tags_from_invoke(self):
"""After ``bind_tools``, ``invoke()`` on the bound wrapper must
still strip ``<think>…</think>`` from the model response. This is
the exact path ``create_agent()`` uses — if it strips, the
``BadRequestError`` from issue #4804 cannot recur."""
mock_llm = MagicMock()
mock_bound = MagicMock()
mock_llm.bind_tools.return_value = mock_bound
mock_response = MagicMock()
mock_response.content = "<think>reasoning trace…</think>visible answer"
mock_bound.invoke.return_value = mock_response
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.remove_think_tags",
wraps=lambda text: text.replace(
"<think>reasoning trace…</think>", ""
),
) as mock_remove:
wrapper = wrap_llm_without_think_tags(mock_llm)
bound_wrapper = wrapper.bind_tools([lambda: None])
bound_wrapper.invoke("test")
mock_bound.invoke.assert_called_once_with("test")
mock_remove.assert_called_once_with(
"<think>reasoning trace…</think>visible answer"
)
# The bound model is what the agent calls — the original
# base LLM must NOT receive the invoke (otherwise the
# bypass is back).
assert not mock_llm.invoke.called
def test_bound_wrapper_delegates_attributes_to_bound_model(self):
"""Attributes on the bound model (e.g. ``model_name``) must still
be reachable through the wrapped binding, so LangChain internals
that introspect the model see the bound model's metadata."""
mock_llm = MagicMock()
mock_bound = MagicMock()
mock_bound.model_name = "qwen3:8b"
mock_llm.bind_tools.return_value = mock_bound
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
bound_wrapper = wrapper.bind_tools([lambda: None])
assert bound_wrapper.model_name == "qwen3:8b"
def test_bound_wrapper_ainvoke_strips_think_tags(self):
"""Async counterpart of ``test_bound_wrapper_strips_think_tags_from_invoke``.
``ainvoke`` is the path async LangGraph nodes use; it must also
pass through ``_normalize_response`` after the re-wrap."""
import asyncio
mock_llm = MagicMock()
mock_bound = MagicMock()
mock_llm.bind_tools.return_value = mock_bound
mock_response = MagicMock()
mock_response.content = "<think>…</think>ok"
mock_bound.ainvoke = AsyncMock(return_value=mock_response)
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.remove_think_tags",
wraps=lambda text: text.replace("<think>…</think>", ""),
):
wrapper = wrap_llm_without_think_tags(mock_llm)
bound_wrapper = wrapper.bind_tools([lambda: None])
result = asyncio.run(bound_wrapper.ainvoke("test"))
mock_bound.ainvoke.assert_called_once_with("test")
# Must actually strip on the async path. Without the bind_tools
# override this fails: the raw bound model is returned and its
# ainvoke never runs _normalize_response, so content keeps the
# <think> block. Regression guard for #4804.
assert result is mock_response
assert result.content == "ok"
class TestBindToolsCreateAgentIntegration:
"""End-to-end guard for #4804.
The unit tests above exercise ``ProcessingLLMWrapper.bind_tools`` in
isolation. This class drives a REAL ``langchain.agents.create_agent`` over
the think-stripping wrapper and asserts the agent's final message has
``<think>…</think>`` stripped — the actual path that regressed. Without the
``bind_tools`` override, ``create_agent`` binds tools to the unwrapped base
model and the ``<think>`` block leaks into the agent's output.
"""
@staticmethod
def _build_agent():
from langchain.agents import create_agent
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
class _FakeThinkModel(BaseChatModel):
"""Reasoning-style model: emits a ``<think>`` block and no tool
calls, so ``create_agent`` runs exactly one model turn and stops."""
@property
def _llm_type(self) -> str:
return "fake-think"
def _generate(
self, messages, stop=None, run_manager=None, **kwargs
):
message = AIMessage(
content="<think>internal reasoning</think>FINAL_ANSWER"
)
return ChatResult(generations=[ChatGeneration(message=message)])
def bind_tools(self, tools, **kwargs):
# The fake never emits tool_calls, so returning self is enough
# for create_agent to bind tools and run a single turn.
return self
@tool
def noop(query: str) -> str:
"""No-op tool so the agent has a non-empty tool list."""
return query
# Patch the settings lookup so wrap_llm_without_think_tags doesn't try
# to read rate-limit settings from a (nonexistent) settings context.
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapped = wrap_llm_without_think_tags(_FakeThinkModel())
return create_agent(model=wrapped, tools=[noop])
def test_create_agent_invoke_strips_think_tags(self):
agent = self._build_agent()
result = agent.invoke({"messages": [{"role": "user", "content": "hi"}]})
final = result["messages"][-1]
assert "<think>" not in final.content
assert final.content == "FINAL_ANSWER"
def test_create_agent_ainvoke_strips_think_tags(self):
import asyncio
agent = self._build_agent()
result = asyncio.run(
agent.ainvoke({"messages": [{"role": "user", "content": "hi"}]})
)
final = result["messages"][-1]
assert "<think>" not in final.content
assert final.content == "FINAL_ANSWER"
class TestGetLlm:
"""Tests for get_llm function.
Many historical tests in this class mocked ``is_llm_registered=False``
to force the procedural ``if/elif`` chain in ``get_llm`` (lines
~405-707 prior to the dead-code deletion). That chain has been
removed; equivalent live-path coverage now lives in
``tests/llm_providers/implementations/test_*_provider.py``. The
remaining tests below exercise the registered-LLM branch and the
coordinator surface (provider normalization, model-name validation,
final guards). The deleted tests are replaced 1:1 by their class-path
equivalents — see commit history for the mapping.
"""
def test_uses_custom_registered_llm(self):
"""Should use custom LLM when registered."""
# Import BaseChatModel for proper spec
from langchain_core.language_models import BaseChatModel
mock_llm = MagicMock(spec=BaseChatModel)
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=True,
):
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry",
return_value=mock_llm,
):
with patch(
"local_deep_research.config.llm_config.wrap_llm_without_think_tags",
return_value=mock_llm,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value="custom_provider",
):
result = get_llm(
provider="custom_provider",
settings_snapshot={"search.tool": "searxng"},
)
assert result is mock_llm
def test_invalid_provider_raises_error(self):
"""Should raise ValueError for invalid provider."""
import pytest
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.model": "test-model",
"llm.temperature": 0.7,
"llm.provider": "invalid_provider",
}.get(key, default)
with pytest.raises(ValueError, match="Invalid provider"):
get_llm(settings_snapshot={"search.tool": "searxng"})
def test_raises_when_model_setting_empty(self):
"""get_llm() must raise ValueError when llm.model is empty string."""
import pytest
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.model": "",
"llm.temperature": 0.7,
"llm.provider": "ollama",
}.get(key, default)
with pytest.raises(
ValueError, match="LLM model not configured"
):
get_llm()
def test_raises_when_model_setting_whitespace_only(self):
"""get_llm() must raise ValueError when llm.model is whitespace."""
import pytest
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.model": " ",
"llm.temperature": 0.7,
"llm.provider": "ollama",
}.get(key, default)
with pytest.raises(
ValueError, match="LLM model not configured"
):
get_llm()
def test_raises_when_model_setting_missing_returns_empty_default(self):
"""get_llm() must raise when snapshot returns empty default."""
import pytest
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=False,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
# Snapshot has neither llm.model nor a custom default;
# function falls back to its own "" default.
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.temperature": 0.7,
"llm.provider": "ollama",
}.get(key, default)
with pytest.raises(
ValueError, match="LLM model not configured"
):
get_llm()
def test_custom_factory_function_is_called(self):
"""Should call factory function for custom registered LLM."""
from langchain_core.language_models import BaseChatModel
mock_llm = MagicMock(spec=BaseChatModel)
mock_factory = MagicMock(return_value=mock_llm)
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=True,
):
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry",
return_value=mock_factory,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.model": "custom-model",
"llm.temperature": 0.5,
"llm.provider": "custom_provider",
"rate_limiting.llm_enabled": False,
}.get(key, default)
get_llm(
model_name="custom-model",
temperature=0.5,
provider="custom_provider",
settings_snapshot={"search.tool": "searxng"},
)
mock_factory.assert_called_once()
call_kwargs = mock_factory.call_args.kwargs
assert call_kwargs["model_name"] == "custom-model"
assert call_kwargs["temperature"] == 0.5
def test_custom_factory_with_invalid_signature_raises(self):
"""Should raise TypeError when factory has invalid signature."""
import pytest
def bad_factory():
return MagicMock()
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=True,
):
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry",
return_value=bad_factory,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.model": "model",
"llm.temperature": 0.7,
"llm.provider": "bad_factory",
"rate_limiting.llm_enabled": False,
}.get(key, default)
with pytest.raises(TypeError, match="invalid signature"):
get_llm(
provider="bad_factory",
settings_snapshot={"search.tool": "searxng"},
)
def test_custom_factory_returning_non_basechatmodel_raises(self):
"""Should raise ValueError when factory returns non-BaseChatModel."""
import pytest
def bad_factory(
model_name=None, temperature=None, settings_snapshot=None
):
return "not a model"
with patch(
"local_deep_research.config.llm_config.is_llm_registered",
return_value=True,
):
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry",
return_value=bad_factory,
):
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot"
) as mock_get:
mock_get.side_effect = lambda key, default=None, **kwargs: {
"llm.model": "model",
"llm.temperature": 0.7,
"llm.provider": "bad_factory",
"rate_limiting.llm_enabled": False,
}.get(key, default)
with pytest.raises(
ValueError, match="must return a BaseChatModel"
):
get_llm(
provider="bad_factory",
settings_snapshot={"search.tool": "searxng"},
)
class TestWrapperStringResponse:
"""Tests for wrapper handling string responses."""
def test_wrapper_handles_string_response(self):
"""Should handle string response from LLM."""
mock_llm = MagicMock()
mock_llm.invoke.return_value = "<think>thought</think>answer"
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
result = wrapper.invoke("test")
# A bare-string return is normalized into a message (so callers can
# always use .content); think tags are still removed.
assert not isinstance(result, str)
assert "answer" in result.content
assert "<think>" not in result.content
def test_wrapper_handles_invoke_exception(self):
"""Should propagate exceptions from LLM invoke."""
import pytest
mock_llm = MagicMock()
mock_llm.invoke.side_effect = RuntimeError("LLM error")
with patch(
"local_deep_research.config.llm_config.get_setting_from_snapshot",
return_value=False,
):
wrapper = wrap_llm_without_think_tags(mock_llm)
with pytest.raises(RuntimeError, match="LLM error"):
wrapper.invoke("test")
class TestImportTimeAutoDiscovery:
"""Guard the import-time provider auto-discovery contract.
``llm_config`` imports ``discover_providers`` (``# noqa: F401``) purely
for its import-time side effect: registering every built-in provider.
Since get_llm() has no fallback construction path, removing that import
would leave the registry empty and break every dispatch. This runs in a
fresh interpreter so the assertion genuinely exercises import-time
registration rather than registrations left over from earlier tests.
"""
def test_importing_llm_config_registers_builtin_providers(self):
import subprocess
import sys
code = (
"from local_deep_research.config import llm_config # noqa: F401\n"
"from local_deep_research.llm import is_llm_registered\n"
"assert is_llm_registered('openai'), 'openai not registered'\n"
"assert is_llm_registered('anthropic'), 'anthropic not registered'\n"
"print('OK')\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"import-time registration failed:\n"
f"stdout={result.stdout}\nstderr={result.stderr}"
)
assert "OK" in result.stdout
class TestDiscoveredProviderOptions:
"""The live UI provider-dropdown path (replaces the removed
get_available_providers()): get_discovered_provider_options() enumerates
all discovered provider classes; get_available_discovered_provider_options()
filters that set by ProviderClass.is_available(settings_snapshot)."""
def test_discovered_options_shape_and_core_providers(self):
from local_deep_research.llm.providers import (
get_discovered_provider_options,
)
options = get_discovered_provider_options()
assert isinstance(options, list) and options
for opt in options:
assert "value" in opt and "label" in opt
values = {opt["value"].lower() for opt in options}
# Core built-in providers must always be discovered. The local
# providers (llamacpp, lmstudio) are included so they can't silently
# drop out of auto-discovery: the model-provider dropdown is derived
# from this set, and #4594 removed the hardcoded LLAMACPP fallback that
# would otherwise have masked such a regression.
for provider in (
"openai",
"anthropic",
"ollama",
"llamacpp",
"lmstudio",
):
assert provider in values
def test_available_options_is_filtered_subset(self):
from local_deep_research.llm.providers import (
get_discovered_provider_options,
get_available_discovered_provider_options,
)
all_values = {
o["value"].lower() for o in get_discovered_provider_options()
}
# With no settings snapshot, no API keys / reachable local servers
# are configured, so the filtered set is a (here empty) subset.
available = get_available_discovered_provider_options(None)
assert isinstance(available, list)
available_values = {o["value"].lower() for o in available}
assert available_values <= all_values