"""Tests for LM Studio LLM provider.""" from unittest.mock import Mock, patch from local_deep_research.llm.providers.implementations.lmstudio import ( LMStudioProvider, ) def _make_setting_side_effect(url_value, api_key_value=""): """Create a side_effect function that returns url_value for llm.lmstudio.url, api_key_value for llm.lmstudio.api_key, and default for everything else. Uses *args, **kwargs to handle variable call patterns from get_setting_from_snapshot. """ def _setting_side_effect(*args, **kwargs): key = args[0] if args else kwargs.get("key", "") default = args[1] if len(args) > 1 else kwargs.get("default", None) if key == "llm.lmstudio.url": return url_value if key == "llm.lmstudio.api_key": return api_key_value return default return _setting_side_effect class TestLMStudioProviderMetadata: """Tests for LMStudioProvider class metadata.""" def test_provider_name(self): """Provider name is correct.""" assert LMStudioProvider.provider_name == "LM Studio" def test_provider_key(self): """Provider key is correct.""" assert LMStudioProvider.provider_key == "LMSTUDIO" def test_is_not_cloud(self): """LM Studio is a local provider.""" assert LMStudioProvider.is_cloud is False def test_company_name(self): """Company name is LM Studio.""" assert LMStudioProvider.company_name == "LM Studio" def test_api_key_setting_is_optional(self): """LM Studio declares its setting key but treats it as optional.""" assert LMStudioProvider.api_key_setting == "llm.lmstudio.api_key" assert LMStudioProvider.api_key_optional is True def test_url_setting(self): """URL setting is defined.""" assert LMStudioProvider.url_setting == "llm.lmstudio.url" def test_default_model(self): """Default model is empty by design — users must explicitly pick one.""" assert LMStudioProvider.default_model == "" def test_default_base_url(self): """Default base URL is localhost.""" assert "localhost" in LMStudioProvider.default_base_url assert "1234" in LMStudioProvider.default_base_url class TestLMStudioCreateLLM: """Tests for create_llm method.""" def test_create_llm_success(self): """Successfully creates ChatOpenAI instance.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: mock_llm = Mock() mock_chat.return_value = mock_llm result = LMStudioProvider.create_llm(model_name="test-model") assert result is mock_llm mock_chat.assert_called_once() def test_create_llm_uses_default_url(self): """Uses default URL when not configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( LMStudioProvider.default_base_url ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: LMStudioProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert "localhost" in call_kwargs["base_url"] assert "1234" in call_kwargs["base_url"] def test_create_llm_uses_custom_url(self): """Uses custom URL from settings. Uses ``localhost`` (legitimate LM Studio host) rather than the placeholder ``custom`` so the SSRF guard added in PR-B doesn't trip on DNS resolution. The test is checking propagation of a custom URL into ChatOpenAI, not SSRF semantics. """ with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:5000/v1" ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: LMStudioProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert "localhost" in call_kwargs["base_url"] assert "5000" in call_kwargs["base_url"] def test_create_llm_uses_placeholder_api_key(self): """Uses placeholder API key for LM Studio.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: LMStudioProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] # Should use a placeholder key like "not-required" assert call_kwargs["api_key"] == "not-required" def test_create_llm_uses_configured_api_key(self): """Uses real API key when llm.lmstudio.api_key is configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1", api_key_value="my-real-api-key" ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: LMStudioProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["api_key"] == "my-real-api-key" def test_create_llm_with_custom_model(self): """Uses custom model when specified.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: LMStudioProvider.create_llm(model_name="my-local-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["model"] == "my-local-model" def test_create_llm_with_custom_temperature(self): """Uses custom temperature.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: LMStudioProvider.create_llm( model_name="test-model", temperature=0.3 ) call_kwargs = mock_chat.call_args[1] assert call_kwargs["temperature"] == 0.3 class TestLMStudioIsAvailable: """Tests for is_available method.""" def test_is_available_true_when_server_responds(self): """Returns True when LM Studio server responds.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch("local_deep_research.security.safe_get") as mock_get: mock_response = Mock() mock_response.status_code = 200 mock_get.return_value = mock_response result = LMStudioProvider.is_available() assert result is True def test_is_available_false_when_server_error(self): """Returns False when server returns error.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch("local_deep_research.security.safe_get") as mock_get: mock_response = Mock() mock_response.status_code = 500 mock_get.return_value = mock_response result = LMStudioProvider.is_available() assert result is False def test_is_available_false_on_connection_error(self): """Returns False when connection fails.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch("local_deep_research.security.safe_get") as mock_get: mock_get.side_effect = Exception("Connection refused") result = LMStudioProvider.is_available() assert result is False def test_is_available_sends_bearer_header_when_key_set(self): """Sends Authorization: Bearer header when a key is configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1", api_key_value="my-secret" ) with patch("local_deep_research.security.safe_get") as mock_get: mock_response = Mock() mock_response.status_code = 200 mock_get.return_value = mock_response LMStudioProvider.is_available() _args, call_kwargs = mock_get.call_args assert call_kwargs["headers"] == { "Authorization": "Bearer my-secret" } def test_is_available_no_auth_header_when_key_empty(self): """Sends no Authorization header when no key is configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch("local_deep_research.security.safe_get") as mock_get: mock_response = Mock() mock_response.status_code = 200 mock_get.return_value = mock_response LMStudioProvider.is_available() _args, call_kwargs = mock_get.call_args assert call_kwargs["headers"] == {} class TestLMStudioRequiresAuth: """Tests for requires_auth_for_models method.""" def test_does_not_require_auth_for_models(self): """LM Studio doesn't require authentication for listing models.""" assert LMStudioProvider.requires_auth_for_models() is False class TestLMStudioListModels: """Tests for the list_models_for_api override.""" def test_list_models_uses_dummy_key_when_no_key_set(self): """Falls back to parent's dummy-key path when no key is configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1" ) with patch("openai.OpenAI") as mock_openai_cls: mock_client = Mock() mock_openai_cls.return_value = mock_client mock_client.models.list.return_value = Mock(data=[]) result = LMStudioProvider.list_models_for_api() assert result == [] call_kwargs = mock_openai_cls.call_args.kwargs assert call_kwargs["api_key"] == "dummy-key-for-models-list" def test_list_models_sends_user_key_when_set(self): """Reads the user's key from settings when caller didn't supply one.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = _make_setting_side_effect( "http://localhost:1234/v1", api_key_value="my-secret" ) with patch("openai.OpenAI") as mock_openai_cls: mock_client = Mock() mock_openai_cls.return_value = mock_client mock_model = Mock() mock_model.id = "llama-3" mock_client.models.list.return_value = Mock(data=[mock_model]) result = LMStudioProvider.list_models_for_api() call_kwargs = mock_openai_cls.call_args.kwargs assert call_kwargs["api_key"] == "my-secret" assert result == [{"value": "llama-3", "label": "llama-3"}] def test_list_models_passthrough_key_not_overwritten(self): """Caller-provided key short-circuits the settings read entirely.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: with patch("openai.OpenAI") as mock_openai_cls: mock_client = Mock() mock_openai_cls.return_value = mock_client mock_client.models.list.return_value = Mock(data=[]) LMStudioProvider.list_models_for_api( api_key="route-provided-key", base_url="http://localhost:1234/v1", ) mock_get_setting.assert_not_called() call_kwargs = mock_openai_cls.call_args.kwargs assert call_kwargs["api_key"] == "route-provided-key"