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
337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""Integration tests for get_llm() dispatch through the provider registry.
|
|
|
|
PR #3984 removed the procedural if/elif provider chain in get_llm(); the
|
|
registry path (auto-discovery -> provider class create_llm) is now the only
|
|
construction path. These tests pin the full dispatch chain end to end:
|
|
|
|
get_llm(provider=..., settings_snapshot=...)
|
|
-> _llm_registry (populated by discover_providers() at import)
|
|
-> <Provider>.create_llm
|
|
-> langchain client constructor (patched at the provider module)
|
|
|
|
Only the langchain client class itself is patched — settings resolution,
|
|
registry dispatch, URL handling, key resolution, and the max_tokens cap all
|
|
execute real production code.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
|
|
|
from local_deep_research.config.llm_config import get_llm
|
|
from local_deep_research.llm import is_llm_registered
|
|
from local_deep_research.llm.providers import get_discovered_provider_options
|
|
from local_deep_research.llm.providers.base import normalize_provider
|
|
|
|
OPENAI_CHAT = (
|
|
"local_deep_research.llm.providers.implementations.openai.ChatOpenAI"
|
|
)
|
|
ANTHROPIC_CHAT = (
|
|
"local_deep_research.llm.providers.implementations.anthropic.ChatAnthropic"
|
|
)
|
|
OLLAMA_CHAT = (
|
|
"local_deep_research.llm.providers.implementations.ollama.ChatOllama"
|
|
)
|
|
# LM Studio and llama.cpp construct their client in the shared parent
|
|
# (OpenAICompatibleProvider._create_llm_instance), so the patch point is
|
|
# openai_base, not the implementation module.
|
|
OPENAI_BASE_CHAT = "local_deep_research.llm.providers.openai_base.ChatOpenAI"
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="module")
|
|
def _ensure_providers_registered():
|
|
"""Re-run auto-discovery in case an earlier test cleared the registry."""
|
|
from local_deep_research.llm.providers import discover_providers
|
|
|
|
discover_providers(force_refresh=True)
|
|
|
|
|
|
def _snapshot(**overrides):
|
|
"""Minimal permissive settings snapshot for the live dispatch path."""
|
|
snap = {
|
|
"llm.supports_max_tokens": True,
|
|
"llm.max_tokens": 4096,
|
|
"rate_limiting.llm_enabled": False,
|
|
"search.tool": "searxng",
|
|
}
|
|
snap.update(overrides)
|
|
return snap
|
|
|
|
|
|
def _fake_llm():
|
|
return FakeListChatModel(responses=["ok"])
|
|
|
|
|
|
class TestRegistryPopulation:
|
|
"""Every built-in provider must be reachable through the registry,
|
|
since get_llm() no longer has any per-provider construction code."""
|
|
|
|
def test_all_discovered_providers_are_registered(self):
|
|
discovered = [
|
|
normalize_provider(option["value"])
|
|
for option in get_discovered_provider_options()
|
|
]
|
|
unregistered = [p for p in discovered if not is_llm_registered(p)]
|
|
assert unregistered == [], (
|
|
f"Providers missing from the LLM registry: {unregistered}. "
|
|
"get_llm() has no fallback construction path for these anymore."
|
|
)
|
|
|
|
|
|
class TestCloudProviderDispatch:
|
|
def test_openai_dispatches_through_provider_class(self):
|
|
snapshot = _snapshot(**{"llm.openai.api_key": "sk-test-key"})
|
|
fake = _fake_llm()
|
|
with patch(OPENAI_CHAT, return_value=fake) as mock_chat:
|
|
result = get_llm(
|
|
provider="openai",
|
|
model_name="gpt-4o-mini",
|
|
temperature=0.5,
|
|
settings_snapshot=snapshot,
|
|
)
|
|
kwargs = mock_chat.call_args.kwargs
|
|
assert kwargs["model"] == "gpt-4o-mini"
|
|
assert kwargs["api_key"] == "sk-test-key"
|
|
assert kwargs["temperature"] == 0.5
|
|
assert result.base_llm is fake
|
|
|
|
def test_anthropic_dispatches_through_provider_class(self):
|
|
snapshot = _snapshot(**{"llm.anthropic.api_key": "sk-ant-test"})
|
|
fake = _fake_llm()
|
|
with patch(ANTHROPIC_CHAT, return_value=fake) as mock_chat:
|
|
result = get_llm(
|
|
provider="anthropic",
|
|
model_name="claude-sonnet-4-5",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
kwargs = mock_chat.call_args.kwargs
|
|
assert kwargs["model"] == "claude-sonnet-4-5"
|
|
assert kwargs["anthropic_api_key"] == "sk-ant-test"
|
|
assert result.base_llm is fake
|
|
|
|
def test_anthropic_missing_key_raises_value_error(self):
|
|
"""Required-key semantics survive on the live path: no placeholder."""
|
|
with pytest.raises(ValueError, match="API key not configured"):
|
|
get_llm(
|
|
provider="anthropic",
|
|
model_name="claude-sonnet-4-5",
|
|
settings_snapshot=_snapshot(),
|
|
)
|
|
|
|
|
|
class TestLocalProviderDispatch:
|
|
def test_lmstudio_appends_v1_suffix(self):
|
|
"""#4532: a URL without /v1 gets the suffix on the live path."""
|
|
snapshot = _snapshot(**{"llm.lmstudio.url": "http://localhost:1234"})
|
|
with patch(OPENAI_BASE_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="lmstudio",
|
|
model_name="qwen2.5-7b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
kwargs = mock_chat.call_args.kwargs
|
|
assert kwargs["base_url"] == "http://localhost:1234/v1"
|
|
assert kwargs["api_key"] == "not-required"
|
|
|
|
def test_lmstudio_does_not_double_v1_suffix(self):
|
|
snapshot = _snapshot(**{"llm.lmstudio.url": "http://localhost:1234/v1"})
|
|
with patch(OPENAI_BASE_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="lmstudio",
|
|
model_name="qwen2.5-7b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert (
|
|
mock_chat.call_args.kwargs["base_url"] == "http://localhost:1234/v1"
|
|
)
|
|
|
|
def test_lmstudio_real_api_key_passed_through(self):
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.lmstudio.url": "http://localhost:1234/v1",
|
|
"llm.lmstudio.api_key": "lms-real-key",
|
|
}
|
|
)
|
|
with patch(OPENAI_BASE_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="lmstudio",
|
|
model_name="qwen2.5-7b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert mock_chat.call_args.kwargs["api_key"] == "lms-real-key"
|
|
|
|
def test_llamacpp_uses_placeholder_key_and_verbatim_url(self):
|
|
"""llama.cpp does NOT force /v1 — the user-provided URL is used as-is."""
|
|
snapshot = _snapshot(**{"llm.llamacpp.url": "http://localhost:8080"})
|
|
with patch(OPENAI_BASE_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="llamacpp",
|
|
model_name="llama-3.1-8b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
kwargs = mock_chat.call_args.kwargs
|
|
assert kwargs["base_url"] == "http://localhost:8080"
|
|
assert kwargs["api_key"] == "not-required"
|
|
|
|
def test_ollama_enable_thinking_false_reaches_chatollama(self):
|
|
"""#3984's headline bug fix: llm.ollama.enable_thinking was silently
|
|
ignored on the live path before this PR."""
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.ollama.url": "http://localhost:11434",
|
|
"llm.ollama.enable_thinking": False,
|
|
}
|
|
)
|
|
fake = _fake_llm()
|
|
with patch(OLLAMA_CHAT, return_value=fake) as mock_chat:
|
|
result = get_llm(
|
|
provider="ollama",
|
|
model_name="deepseek-r1:14b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
kwargs = mock_chat.call_args.kwargs
|
|
assert kwargs["reasoning"] is False
|
|
assert kwargs["model"] == "deepseek-r1:14b"
|
|
assert result.base_llm is fake
|
|
|
|
def test_ollama_enable_thinking_default_true(self):
|
|
snapshot = _snapshot(**{"llm.ollama.url": "http://localhost:11434"})
|
|
with patch(OLLAMA_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="ollama",
|
|
model_name="deepseek-r1:14b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert mock_chat.call_args.kwargs["reasoning"] is True
|
|
|
|
|
|
class TestMaxTokensCap:
|
|
"""The 80%-of-context-window cap previously lived only in dead code;
|
|
these pin it on the live path end to end through get_llm()."""
|
|
|
|
def test_cloud_max_tokens_capped_at_80_percent_of_window(self):
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.openai.api_key": "sk-test-key",
|
|
"llm.context_window_unrestricted": False,
|
|
"llm.context_window_size": 10000,
|
|
"llm.max_tokens": 200000,
|
|
}
|
|
)
|
|
with patch(OPENAI_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="openai",
|
|
model_name="gpt-4o-mini",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert mock_chat.call_args.kwargs["max_tokens"] == 8000
|
|
|
|
def test_cloud_unrestricted_window_passes_max_tokens_uncapped(self):
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.openai.api_key": "sk-test-key",
|
|
"llm.context_window_unrestricted": True,
|
|
"llm.max_tokens": 200000,
|
|
}
|
|
)
|
|
with patch(OPENAI_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="openai",
|
|
model_name="gpt-4o-mini",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert mock_chat.call_args.kwargs["max_tokens"] == 200000
|
|
|
|
def test_ollama_num_ctx_from_shared_helper_no_max_tokens(self):
|
|
"""Ollama's window resolves through the shared helper (same source
|
|
as context_limit bookkeeping); no max_tokens kwarg is passed since
|
|
ChatOllama silently ignores it."""
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.ollama.url": "http://localhost:11434",
|
|
"llm.local_context_window_size": 4096,
|
|
"llm.max_tokens": 200000,
|
|
}
|
|
)
|
|
with patch(OLLAMA_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="ollama",
|
|
model_name="llama3.1:8b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
kwargs = mock_chat.call_args.kwargs
|
|
assert kwargs["num_ctx"] == 4096
|
|
assert "max_tokens" not in kwargs
|
|
|
|
def test_lmstudio_capped_by_local_window_not_cloud_window(self):
|
|
"""LM Studio resolves its window via provider_key through the
|
|
LOCAL_PROVIDERS branch — the cloud llm.context_window_size must
|
|
play no role."""
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.lmstudio.url": "http://localhost:1234/v1",
|
|
"llm.context_window_unrestricted": False,
|
|
"llm.context_window_size": 128000,
|
|
"llm.local_context_window_size": 4096,
|
|
"llm.max_tokens": 200000,
|
|
}
|
|
)
|
|
with patch(OPENAI_BASE_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="lmstudio",
|
|
model_name="qwen2.5-7b",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert mock_chat.call_args.kwargs["max_tokens"] == int(4096 * 0.8)
|
|
|
|
def test_unset_max_tokens_omits_kwarg(self):
|
|
"""Partial snapshots without llm.max_tokens must not inject a
|
|
hardcoded default — the provider SDK's own default applies."""
|
|
snapshot = _snapshot(**{"llm.openai.api_key": "sk-test-key"})
|
|
del snapshot["llm.max_tokens"]
|
|
with patch(OPENAI_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="openai",
|
|
model_name="gpt-4o-mini",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert "max_tokens" not in mock_chat.call_args.kwargs
|
|
|
|
def test_supports_max_tokens_false_omits_kwarg(self):
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.openai.api_key": "sk-test-key",
|
|
"llm.supports_max_tokens": False,
|
|
}
|
|
)
|
|
with patch(OPENAI_CHAT, return_value=_fake_llm()) as mock_chat:
|
|
get_llm(
|
|
provider="openai",
|
|
model_name="gpt-4o-mini",
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert "max_tokens" not in mock_chat.call_args.kwargs
|
|
|
|
|
|
class TestContextLimitBookkeeping:
|
|
def test_context_limit_set_in_research_context(self):
|
|
"""Overflow detection relies on context_limit being populated even
|
|
though the registered path returns before get_llm's own bookkeeping."""
|
|
snapshot = _snapshot(
|
|
**{
|
|
"llm.openai.api_key": "sk-test-key",
|
|
"llm.context_window_unrestricted": False,
|
|
"llm.context_window_size": 10000,
|
|
}
|
|
)
|
|
research_context = {}
|
|
with patch(OPENAI_CHAT, return_value=_fake_llm()):
|
|
get_llm(
|
|
provider="openai",
|
|
model_name="gpt-4o-mini",
|
|
settings_snapshot=snapshot,
|
|
research_context=research_context,
|
|
)
|
|
assert research_context["context_limit"] == 10000
|