"""High-value edge case tests for LM Studio, OpenRouter, xAI, and IONOS providers. Focuses on error handling paths, cross-provider consistency, edge cases in configuration, list_models behavior, and provider-specific quirks. """ import pytest from unittest.mock import Mock, patch from local_deep_research.llm.providers.implementations.lmstudio import ( LMStudioProvider, ) from local_deep_research.llm.providers.implementations.openrouter import ( OpenRouterProvider, ) from local_deep_research.llm.providers.implementations.xai import ( XAIProvider, ) from local_deep_research.llm.providers.implementations.ionos import ( IONOSProvider, ) from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) from local_deep_research.llm.providers.base import BaseLLMProvider def _lmstudio_settings(url_value, extras=None): """Side-effect factory for LM Studio (no API key, URL-based).""" base = { "llm.lmstudio.url": url_value, "llm.max_tokens": None, } if extras: base.update(extras) def _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) return base.get(key, default) return _side_effect def _cloud_settings(api_key_setting, api_key_value, extras=None): """Build a mock side-effect for cloud provider settings.""" base = { api_key_setting: api_key_value, "llm.max_tokens": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, } if extras: base.update(extras) def _side_effect(key, default=None, *args, **kwargs): return base.get(key, default) return _side_effect # =========================================================================== # LM Studio edge cases # =========================================================================== class TestLMStudioEdgeCases: """Edge cases unique to the LM Studio provider.""" def test_is_available_with_non_200_status_codes(self): """Various non-200 codes return False.""" for status in [301, 403, 404, 502, 503]: with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _lmstudio_settings( "http://localhost:1234/v1" ) with patch("local_deep_research.security.safe_get") as mock_get: mock_response = Mock(status_code=status) mock_get.return_value = mock_response assert LMStudioProvider.is_available() is False, ( f"Expected False for HTTP {status}" ) def test_is_available_timeout_returns_false(self): """A connection timeout is handled gracefully.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _lmstudio_settings("http://localhost:1234/v1") with patch("local_deep_research.security.safe_get") as mock_get: mock_get.side_effect = TimeoutError("Connection timed out") assert LMStudioProvider.is_available() is False def test_lmstudio_inherits_from_openai_compatible(self): """LM Studio provider inherits from OpenAICompatibleProvider.""" assert issubclass(LMStudioProvider, OpenAICompatibleProvider) assert issubclass(LMStudioProvider, BaseLLMProvider) # =========================================================================== # OpenRouter edge cases # =========================================================================== class TestOpenRouterEdgeCases: """Edge cases unique to the OpenRouter provider.""" def test_create_llm_with_whitespace_only_api_key(self): """Whitespace-only API key is normalized as missing and raises (required cloud provider).""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings( "llm.openrouter.api_key", " " ) with pytest.raises(ValueError, match="API key not configured"): OpenRouterProvider.create_llm(model_name="test-model") def test_create_llm_error_message_contains_setting_name(self): """Error message tells user which setting to configure.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings( "llm.openrouter.api_key", None ) with pytest.raises(ValueError) as exc_info: OpenRouterProvider.create_llm() assert "llm.openrouter.api_key" in str(exc_info.value) def test_list_models_for_api_connection_error_returns_empty(self): """Network error returns empty list, not exception.""" with patch("openai.OpenAI") as mock_openai: mock_openai.side_effect = ConnectionError("Network unreachable") result = OpenRouterProvider.list_models_for_api(api_key="key") assert result == [] def test_openrouter_inherits_from_openai_compatible(self): """OpenRouter provider inherits from OpenAICompatibleProvider.""" assert issubclass(OpenRouterProvider, OpenAICompatibleProvider) assert issubclass(OpenRouterProvider, BaseLLMProvider) def test_create_llm_passes_max_tokens_when_configured(self): """max_tokens from settings is forwarded to ChatOpenAI.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings( "llm.openrouter.api_key", "test-key", extras={"llm.max_tokens": 2048}, ) with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat: mock_chat.return_value = Mock() OpenRouterProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["max_tokens"] == 2048 # =========================================================================== # xAI edge cases # =========================================================================== class TestXAIEdgeCases: """Edge cases unique to the xAI Grok provider.""" def test_create_llm_with_empty_string_api_key(self): """Empty string API key raises ValueError.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings("llm.xai.api_key", "") with pytest.raises(ValueError, match="(?i)api key"): XAIProvider.create_llm() def test_create_llm_error_message_contains_xai_setting(self): """Error message references the correct xAI setting key.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings("llm.xai.api_key", None) with pytest.raises(ValueError) as exc_info: XAIProvider.create_llm() assert "llm.xai.api_key" in str(exc_info.value) def test_list_models_for_api_requires_key(self): """xAI requires auth so list_models_for_api returns [] without key.""" result = XAIProvider.list_models_for_api(api_key=None) assert result == [] def test_is_available_with_exception_in_settings(self): """Exception during settings retrieval returns False.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = RuntimeError("settings DB corrupted") assert XAIProvider.is_available() is False def test_xai_inherits_from_openai_compatible(self): """xAI provider inherits from OpenAICompatibleProvider.""" assert issubclass(XAIProvider, OpenAICompatibleProvider) assert issubclass(XAIProvider, BaseLLMProvider) # =========================================================================== # IONOS edge cases # =========================================================================== class TestIONOSEdgeCases: """Edge cases unique to the IONOS provider.""" def test_create_llm_with_none_api_key_raises(self): """Explicitly None API key raises ValueError.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings("llm.ionos.api_key", None) with pytest.raises(ValueError, match="(?i)api key"): IONOSProvider.create_llm() def test_create_llm_error_message_references_ionos_setting(self): """Error message tells the user exactly which setting is missing.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.side_effect = _cloud_settings("llm.ionos.api_key", None) with pytest.raises(ValueError) as exc_info: IONOSProvider.create_llm() assert "llm.ionos.api_key" in str(exc_info.value) def test_list_models_for_api_without_key_returns_empty(self): """IONOS requires auth so returns [] without a key.""" result = IONOSProvider.list_models_for_api(api_key=None) assert result == [] def test_is_available_with_whitespace_only_key(self): """Whitespace-only API key is treated as unavailable.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_gs: mock_gs.return_value = " " assert IONOSProvider.is_available() is False def test_ionos_inherits_from_openai_compatible(self): """IONOS provider inherits from OpenAICompatibleProvider.""" assert issubclass(IONOSProvider, OpenAICompatibleProvider) assert issubclass(IONOSProvider, BaseLLMProvider) # =========================================================================== # Cross-provider consistency tests # =========================================================================== class TestCrossProviderConsistency: """Verify consistent behaviour across all four providers.""" @pytest.mark.parametrize( "provider_cls,expected_key", [ (LMStudioProvider, "LMSTUDIO"), (OpenRouterProvider, "OPENROUTER"), (XAIProvider, "XAI"), (IONOSProvider, "IONOS"), ], ) def test_provider_key_is_uppercase(self, provider_cls, expected_key): """All provider_key values should be uppercase identifiers.""" assert provider_cls.provider_key == expected_key @pytest.mark.parametrize( "provider_cls,is_cloud", [ (LMStudioProvider, False), (OpenRouterProvider, True), (XAIProvider, True), (IONOSProvider, True), ], ) def test_cloud_flag_consistency(self, provider_cls, is_cloud): """Cloud flag matches expected value for each provider.""" assert provider_cls.is_cloud is is_cloud @pytest.mark.parametrize( "provider_cls", [LMStudioProvider, OpenRouterProvider, XAIProvider, IONOSProvider], ) def test_default_base_url_is_valid(self, provider_cls): """All providers must have a non-empty default_base_url with a scheme.""" url = provider_cls.default_base_url assert url is not None assert url.startswith("http://") or url.startswith("https://") @pytest.mark.parametrize( "provider_cls", [LMStudioProvider, OpenRouterProvider, XAIProvider, IONOSProvider], ) def test_default_model_is_empty(self, provider_cls): """Every provider must declare an empty default model — users must explicitly pick one. No silent fallbacks.""" assert provider_cls.default_model == "" @pytest.mark.parametrize( "provider_cls", [OpenRouterProvider, XAIProvider, IONOSProvider], ) def test_cloud_providers_require_api_key_setting(self, provider_cls): """Cloud providers must define an api_key_setting.""" assert provider_cls.api_key_setting is not None assert provider_cls.api_key_setting.startswith("llm.") def test_local_provider_does_not_require_api_key(self): """LM Studio (local) 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 @pytest.mark.parametrize( "provider_cls", [LMStudioProvider, OpenRouterProvider, XAIProvider, IONOSProvider], ) def test_all_providers_have_company_name(self, provider_cls): """Every provider must have a company_name for UI display.""" assert provider_cls.company_name assert isinstance(provider_cls.company_name, str) assert len(provider_cls.company_name) > 0