"""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) -> .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