""" 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 = "internal reasoningfinal answer" llm.invoke.return_value = resp w = self._make_wrapper(llm) result = w.invoke("prompt") assert "" 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 = "thoughtanswer" 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 "" 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 = "tfinal" 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 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="reasoninganswer", 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="tasync 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