"""Tests for OpenAI LLM provider.""" import pytest from unittest.mock import Mock, patch from local_deep_research.llm.providers.implementations.openai import ( OpenAIProvider, ) class TestOpenAIProviderMetadata: """Tests for OpenAIProvider class metadata.""" def test_provider_name(self): """Provider name is correct.""" assert OpenAIProvider.provider_name == "OpenAI" def test_provider_key(self): """Provider key is correct.""" assert OpenAIProvider.provider_key == "OPENAI" def test_is_cloud(self): """OpenAI is a cloud provider.""" assert OpenAIProvider.is_cloud is True def test_company_name(self): """Company name is OpenAI.""" assert OpenAIProvider.company_name == "OpenAI" def test_api_key_setting(self): """API key setting is correct.""" assert OpenAIProvider.api_key_setting == "llm.openai.api_key" def test_default_model(self): """Default model is empty by design — users must explicitly pick one.""" assert OpenAIProvider.default_model == "" def test_default_base_url(self): """Default base URL is OpenAI API.""" assert OpenAIProvider.default_base_url == "https://api.openai.com/v1" class TestOpenAICreateLLM: """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: OpenAIProvider.create_llm() assert "api key" in str(exc_info.value).lower() assert "llm.openai.api_key" in str(exc_info.value) def test_create_llm_with_valid_api_key(self): """Successfully creates ChatOpenAI instance with valid API key.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-openai-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: mock_llm = Mock() mock_chat.return_value = mock_llm result = OpenAIProvider.create_llm(model_name="test-model") assert result is mock_llm mock_chat.assert_called_once() def test_create_llm_uses_default_model_when_none(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.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } 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"): OpenAIProvider.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.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="gpt-4") call_kwargs = mock_chat.call_args[1] assert call_kwargs["model"] == "gpt-4" def test_create_llm_passes_temperature(self): """Passes temperature parameter.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm( model_name="test-model", temperature=0.5 ) call_kwargs = mock_chat.call_args[1] assert call_kwargs["temperature"] == 0.5 def test_create_llm_passes_max_tokens_when_set(self): """Passes max_tokens when configured in settings.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": 4096, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["max_tokens"] == 4096 def test_create_llm_passes_streaming_when_set(self): """Passes streaming parameter when configured.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": True, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["streaming"] is True def test_create_llm_passes_max_retries_when_set(self): """Passes max_retries parameter when configured.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": 5, "llm.request_timeout": None, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["max_retries"] == 5 def test_create_llm_passes_request_timeout_when_set(self): """Passes request_timeout parameter when configured.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": 120, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["request_timeout"] == 120 def test_create_llm_passes_api_base_when_set(self): """Passes custom API base URL when configured.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": "https://custom-openai-proxy.com/v1", "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } return settings_map.get(key, default) # Patch the SSRF guard to a passthrough so this config-passthrough # test does not depend on live DNS resolution of the placeholder host # (matches tests/test_api_key_configuration.py convention). The guard # is imported at module level into the openai implementation module, so # patch it there. with ( patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot", side_effect=mock_get_setting_side_effect, ), patch( "local_deep_research.llm.providers.implementations.openai.assert_base_url_safe", side_effect=lambda url, **_kwargs: url, ), patch( "local_deep_research.llm.providers.implementations.openai.ChatOpenAI" ) as mock_chat, ): OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert ( call_kwargs["openai_api_base"] == "https://custom-openai-proxy.com/v1" ) def test_create_llm_passes_organization_when_set(self): """Passes OpenAI organization when configured.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": "org-12345", "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": None, } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["openai_organization"] == "org-12345" def test_create_llm_converts_max_tokens_to_int(self): """Converts max_tokens to integer from string.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.openai.api_key": "test-key", "llm.openai.api_base": None, "llm.openai.organization": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": None, "llm.max_tokens": "2048", # String value } 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.implementations.openai.ChatOpenAI" ) as mock_chat: OpenAIProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert call_kwargs["max_tokens"] == 2048 assert isinstance(call_kwargs["max_tokens"], int) def test_create_llm_handles_settings_context_error(self): """Handles NoSettingsContextError for optional parameters.""" from local_deep_research.config.thread_settings import ( NoSettingsContextError, ) call_count = 0 def mock_get_setting_side_effect(key, default=None, *args, **kwargs): nonlocal call_count call_count += 1 # First call gets API key, subsequent calls raise for optional params if key == "llm.openai.api_key": return "test-key" raise NoSettingsContextError("No settings context") 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.implementations.openai.ChatOpenAI" ) as mock_chat: # Should not raise - NoSettingsContextError is caught for optional params OpenAIProvider.create_llm(model_name="test-model") mock_chat.assert_called_once() def test_create_llm_with_empty_api_key_raises(self): """Raises ValueError 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 = "" with pytest.raises(ValueError) as exc_info: OpenAIProvider.create_llm() assert "api key" in str(exc_info.value).lower() class TestOpenAIIsAvailable: """Tests for is_available method.""" def test_is_available_true_when_key_exists(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-key" result = OpenAIProvider.is_available() assert result is True def test_is_available_false_when_no_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 = OpenAIProvider.is_available() assert result is False def test_is_available_false_when_empty_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 = OpenAIProvider.is_available() assert result is False def test_is_available_false_on_exception(self): """Returns False when exception occurs.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.side_effect = Exception("Settings error") result = OpenAIProvider.is_available() assert result is False def test_is_available_with_settings_snapshot(self): """Uses provided settings snapshot.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: mock_get_setting.return_value = "test-key" snapshot = {"llm.openai.api_key": "test-key"} result = OpenAIProvider.is_available(settings_snapshot=snapshot) assert result is True # Verify settings_snapshot was passed call_kwargs = mock_get_setting.call_args[1] assert call_kwargs.get("settings_snapshot") == snapshot class TestOpenAIListModels: """Tests for model listing functionality (inherited from base).""" def test_requires_auth_for_models_returns_true(self): """OpenAI requires authentication for model listing.""" # OpenAI doesn't override this, so it uses base class behavior assert OpenAIProvider.requires_auth_for_models() is True def test_list_models_for_api_returns_empty_without_key(self): """Returns empty list when no API key provided.""" result = OpenAIProvider.list_models_for_api(api_key=None) assert result == [] def test_list_models_for_api_with_valid_key(self): """Returns models when valid API key provided.""" with patch("openai.OpenAI") as mock_openai: mock_client = Mock() mock_openai.return_value = mock_client mock_model1 = Mock() mock_model1.id = "gpt-4" mock_model2 = Mock() mock_model2.id = "gpt-3.5-turbo" models_response = Mock() models_response.data = [mock_model1, mock_model2] mock_client.models.list.return_value = models_response result = OpenAIProvider.list_models_for_api(api_key="test-key") assert len(result) == 2 assert {"value": "gpt-4", "label": "gpt-4"} in result assert { "value": "gpt-3.5-turbo", "label": "gpt-3.5-turbo", } in result def test_list_models_for_api_handles_exception(self): """Returns empty list on exception.""" with patch("openai.OpenAI") as mock_openai: mock_openai.side_effect = Exception("API Error") result = OpenAIProvider.list_models_for_api(api_key="test-key") assert result == [] def test_list_models_uses_settings_for_api_key(self): """list_models() gets API key from settings.""" with patch( "local_deep_research.config.thread_settings.get_setting_from_snapshot" ) as mock_get_setting: with patch.object( OpenAIProvider, "list_models_for_api" ) as mock_list_for_api: mock_get_setting.return_value = "settings-api-key" mock_list_for_api.return_value = [] OpenAIProvider.list_models() # Verify API key was retrieved from settings mock_get_setting.assert_called() def test_list_models_for_api_uses_provided_base_url(self): """Uses provided base URL for model listing.""" with patch("openai.OpenAI") as mock_openai: mock_client = Mock() mock_openai.return_value = mock_client mock_client.models.list.return_value = Mock(data=[]) OpenAIProvider.list_models_for_api( api_key="test-key", base_url="https://custom-api.com/v1" ) # Verify custom base_url was passed to OpenAI client call_kwargs = mock_openai.call_args[1] assert call_kwargs["base_url"] == "https://custom-api.com/v1" def test_list_models_for_api_uses_default_url_when_none(self): """Uses default base URL when none provided.""" with patch("openai.OpenAI") as mock_openai: mock_client = Mock() mock_openai.return_value = mock_client mock_client.models.list.return_value = Mock(data=[]) OpenAIProvider.list_models_for_api( api_key="test-key", base_url=None ) call_kwargs = mock_openai.call_args[1] assert call_kwargs["base_url"] == OpenAIProvider.default_base_url