""" Tests for OpenAI-compatible base provider. """ import pytest from unittest.mock import Mock, patch from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) class TestOpenAICompatibleProviderMetadata: """Tests for OpenAICompatibleProvider class metadata.""" def test_provider_name(self): """Default provider name is set.""" assert OpenAICompatibleProvider.provider_name == "openai_endpoint" def test_default_base_url(self): """Default base URL is OpenAI.""" assert ( OpenAICompatibleProvider.default_base_url == "https://api.openai.com/v1" ) def test_default_model(self): """Default model is empty by design — users must explicitly pick one.""" assert OpenAICompatibleProvider.default_model == "" def test_api_key_setting(self): """API key setting is defined.""" assert OpenAICompatibleProvider.api_key_setting is not None class TestOpenAICompatibleCreateLLM: """Tests for create_llm method.""" def test_create_llm_raises_without_api_key(self): """Raises ValueError when API key not configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.return_value = None with pytest.raises(ValueError) as exc_info: OpenAICompatibleProvider.create_llm() assert "api key" in str(exc_info.value).lower() def test_create_llm_success(self): """Successfully creates ChatOpenAI instance.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai_endpoint.api_key": "test-api-key", "llm.max_tokens": 4096, "llm.streaming": True, "llm.max_retries": 3, "llm.request_timeout": 60, } return settings_map.get(key, default) with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = mock_get_setting_side_effect with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat_openai: mock_llm = Mock() mock_chat_openai.return_value = mock_llm result = OpenAICompatibleProvider.create_llm( model_name="test-model" ) assert result is mock_llm mock_chat_openai.assert_called_once() def test_create_llm_uses_default_model(self): """Raises ValueError when no model name is provided (no silent default).""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai_endpoint.api_key": "test-api-key", "llm.max_tokens": 4096, "llm.streaming": True, "llm.max_retries": 3, "llm.request_timeout": 60, } return settings_map.get(key, default) with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = mock_get_setting_side_effect with pytest.raises(ValueError, match="model not configured"): OpenAICompatibleProvider.create_llm() def test_create_llm_with_custom_model(self): """Uses custom model when specified.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai_endpoint.api_key": "test-api-key", "llm.max_tokens": 4096, "llm.streaming": True, "llm.max_retries": 3, "llm.request_timeout": 60, } return settings_map.get(key, default) with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = mock_get_setting_side_effect with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat_openai: OpenAICompatibleProvider.create_llm(model_name="gpt-4") call_kwargs = mock_chat_openai.call_args[1] assert call_kwargs["model"] == "gpt-4" def test_create_llm_with_custom_temperature(self): """Uses custom temperature.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai_endpoint.api_key": "test-api-key", "llm.max_tokens": 4096, "llm.streaming": True, "llm.max_retries": 3, "llm.request_timeout": 60, } return settings_map.get(key, default) with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = mock_get_setting_side_effect with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat_openai: OpenAICompatibleProvider.create_llm( model_name="test-model", temperature=0.2 ) call_kwargs = mock_chat_openai.call_args[1] assert call_kwargs["temperature"] == 0.2 def test_create_llm_with_custom_base_url(self): """Uses custom base URL from kwargs.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai_endpoint.api_key": "test-api-key", "llm.max_tokens": 4096, "llm.streaming": True, "llm.max_retries": 3, "llm.request_timeout": 60, } return settings_map.get(key, default) with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = mock_get_setting_side_effect with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat_openai: OpenAICompatibleProvider.create_llm( model_name="test-model", base_url="https://custom.api.com/v1", ) call_kwargs = mock_chat_openai.call_args[1] assert "custom.api.com" in call_kwargs["base_url"] class TestOpenAICompatibleIsAvailable: """Tests for is_available method.""" def test_available_with_api_key(self): """Returns True when API key is configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.return_value = "test-api-key" result = OpenAICompatibleProvider.is_available() assert result is True def test_not_available_without_api_key(self): """Returns False when API key is not configured.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.return_value = None result = OpenAICompatibleProvider.is_available() assert result is False def test_not_available_with_empty_api_key(self): """Returns False when API key is empty string.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.return_value = "" result = OpenAICompatibleProvider.is_available() assert result is False class TestOpenAICompatibleRequiresAuth: """Tests for requires_auth_for_models method.""" def test_requires_auth_by_default(self): """Returns True by default.""" result = OpenAICompatibleProvider.requires_auth_for_models() assert result is True class TestOpenAICompatibleListModels: """Tests for list_models methods.""" def test_list_models_for_api_without_key(self): """Returns empty list when no API key.""" result = OpenAICompatibleProvider.list_models_for_api() assert result == [] def test_list_models_for_api_with_key(self, mock_openai_client): """Returns models when API key provided.""" with patch("openai.OpenAI") as mock_openai: mock_openai.return_value = mock_openai_client result = OpenAICompatibleProvider.list_models_for_api( api_key="test-key" ) assert isinstance(result, list) assert len(result) == 2 assert result[0]["value"] == "gpt-4" assert result[1]["value"] == "gpt-3.5-turbo" def test_list_models_for_api_error_handling(self): """Returns empty list on error.""" with patch("openai.OpenAI") as mock_openai: mock_openai.side_effect = Exception("API error") result = OpenAICompatibleProvider.list_models_for_api( api_key="test-key" ) assert result == [] def test_list_models_uses_settings(self): """list_models() gets API key from settings.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.return_value = "settings-key" with patch("openai.OpenAI") as mock_openai: mock_client = Mock() mock_model = Mock() mock_model.id = "gpt-4" mock_client.models.list.return_value = Mock(data=[mock_model]) mock_openai.return_value = mock_client result = OpenAICompatibleProvider.list_models() assert len(result) == 1 def test_list_models_for_api_with_custom_base_url(self, mock_openai_client): """Passes custom base_url to OpenAI client. REGRESSION TEST: This verifies the fix for issue where custom OpenAI endpoints with private IPs (172.x, 10.x, etc.) were not working because the base_url wasn't being passed correctly to the OpenAI client. """ with patch("openai.OpenAI") as mock_openai: mock_openai.return_value = mock_openai_client custom_url = "http://172.19.0.5:8000/v1" OpenAICompatibleProvider.list_models_for_api( api_key="test-key", base_url=custom_url ) # Verify OpenAI client was created with the custom URL mock_openai.assert_called_once() call_kwargs = mock_openai.call_args[1] assert call_kwargs["base_url"] == custom_url def test_list_models_for_api_uses_default_url_when_none( self, mock_openai_client ): """Uses default_base_url when no base_url provided.""" with patch("openai.OpenAI") as mock_openai: mock_openai.return_value = mock_openai_client OpenAICompatibleProvider.list_models_for_api(api_key="test-key") call_kwargs = mock_openai.call_args[1] assert ( call_kwargs["base_url"] == OpenAICompatibleProvider.default_base_url ) def test_list_models_for_api_private_ip_addresses(self, mock_openai_client): """Works correctly with various private IP address formats. REGRESSION TEST: Ensures custom endpoints with private IPs work. These are common for self-hosted vLLM, Ollama, and other local servers. """ private_urls = [ "http://10.0.0.100:8000/v1", # Class A private "http://172.16.0.50:5000/v1", # Class B private "http://172.19.0.5:8000/v1", # Class B private (Docker) "http://192.168.1.100:11434/v1", # Class C private "http://localhost:8000/v1", # localhost "http://127.0.0.1:8000/v1", # loopback ] for url in private_urls: with patch("openai.OpenAI") as mock_openai: mock_openai.return_value = mock_openai_client result = OpenAICompatibleProvider.list_models_for_api( api_key="test-key", base_url=url ) # Verify URL was passed correctly call_kwargs = mock_openai.call_args[1] assert call_kwargs["base_url"] == url, ( f"URL {url} was not passed correctly" ) assert len(result) == 2, f"Failed to get models for {url}" class TestOpenAICompatibleCreateLLMInstance: """Tests for _create_llm_instance method.""" def test_create_instance_bypasses_api_key_check(self): """_create_llm_instance doesn't require API key from settings.""" with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat_openai: mock_llm = Mock() mock_chat_openai.return_value = mock_llm result = OpenAICompatibleProvider._create_llm_instance( model_name="test-model", api_key="provided-key", ) assert result is mock_llm call_kwargs = mock_chat_openai.call_args[1] assert call_kwargs["api_key"] == "provided-key" def test_create_instance_uses_dummy_key_by_default(self): """Uses dummy key when none provided.""" with patch( "local_deep_research.llm.providers.openai_base.ChatOpenAI" ) as mock_chat_openai: mock_llm = Mock() mock_chat_openai.return_value = mock_llm OpenAICompatibleProvider._create_llm_instance( model_name="test-model" ) call_kwargs = mock_chat_openai.call_args[1] assert call_kwargs["api_key"] == "not-required" class TestOpenAICompatibleSubclass: """Tests for subclassing OpenAICompatibleProvider.""" def test_subclass_overrides_work(self): """Subclass can override provider settings.""" class CustomProvider(OpenAICompatibleProvider): provider_name = "Custom Provider" api_key_setting = "llm.custom.api_key" default_base_url = "https://custom.api.com/v1" default_model = "custom-model" assert CustomProvider.provider_name == "Custom Provider" assert CustomProvider.api_key_setting == "llm.custom.api_key" assert CustomProvider.default_base_url == "https://custom.api.com/v1" assert CustomProvider.default_model == "custom-model" def test_subclass_inherits_methods(self): """Subclass inherits provider methods.""" class CustomProvider(OpenAICompatibleProvider): provider_name = "Custom" api_key_setting = None # No API key required # Should inherit is_available assert hasattr(CustomProvider, "is_available") assert hasattr(CustomProvider, "create_llm") assert hasattr(CustomProvider, "list_models") def test_subclass_no_api_key_required(self): """Provider with no api_key_setting is always available.""" class NoAuthProvider(OpenAICompatibleProvider): provider_name = "No Auth" api_key_setting = None # No API key required result = NoAuthProvider.is_available() assert result is True