"""Tests for Google/Gemini LLM provider.""" import pytest from unittest.mock import Mock, patch from local_deep_research.llm.providers.implementations.google import ( GoogleProvider, ) class TestGoogleProviderMetadata: """Tests for GoogleProvider class metadata.""" def test_provider_name(self): """Provider name is correct.""" assert GoogleProvider.provider_name == "Google Gemini" def test_provider_key(self): """Provider key is correct.""" assert GoogleProvider.provider_key == "GOOGLE" def test_is_cloud(self): """Google is a cloud provider.""" assert GoogleProvider.is_cloud is True def test_company_name(self): """Company name is Google.""" assert GoogleProvider.company_name == "Google" def test_api_key_setting(self): """API key setting is correct.""" assert GoogleProvider.api_key_setting == "llm.google.api_key" def test_default_model(self): """Default model is empty by design — users must explicitly pick one.""" assert GoogleProvider.default_model == "" def test_default_base_url(self): """Default base URL is Google's OpenAI-compatible endpoint.""" assert ( "generativelanguage.googleapis.com" in GoogleProvider.default_base_url ) assert "openai" in GoogleProvider.default_base_url class TestGoogleRequiresAuth: """Tests for requires_auth_for_models method.""" def test_requires_auth_for_models_returns_true(self): """Google requires authentication for model listing.""" assert GoogleProvider.requires_auth_for_models() is True class TestGoogleListModelsForApi: """Tests for list_models_for_api method (custom implementation).""" def test_returns_empty_without_api_key(self): """Returns empty list when no API key provided.""" result = GoogleProvider.list_models_for_api(api_key=None) assert result == [] def test_returns_empty_with_empty_api_key(self): """Returns empty list when API key is empty string.""" result = GoogleProvider.list_models_for_api(api_key="") assert result == [] def test_lists_models_with_valid_key(self): """Returns models when valid API key provided.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "name": "models/gemini-1.5-flash", "supportedGenerationMethods": ["generateContent"], }, { "name": "models/gemini-1.5-pro", "supportedGenerationMethods": ["generateContent"], }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api( api_key="test-google-key" ) assert len(result) == 2 assert { "value": "gemini-1.5-flash", "label": "gemini-1.5-flash", } in result assert { "value": "gemini-1.5-pro", "label": "gemini-1.5-pro", } in result def test_strips_models_prefix_from_name(self): """Strips 'models/' prefix from model names.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "name": "models/gemini-2.0-flash-exp", "supportedGenerationMethods": ["generateContent"], }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") # Should strip "models/" prefix assert result[0]["value"] == "gemini-2.0-flash-exp" assert "models/" not in result[0]["value"] def test_handles_model_without_prefix(self): """Handles model names that don't have 'models/' prefix.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "name": "gemini-custom", "supportedGenerationMethods": ["generateContent"], }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") assert result[0]["value"] == "gemini-custom" def test_filters_embedding_models(self): """Filters out models that don't support generateContent.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "name": "models/gemini-1.5-flash", "supportedGenerationMethods": ["generateContent"], }, { "name": "models/embedding-001", "supportedGenerationMethods": [ "embedContent" ], # Embedding model }, { "name": "models/text-embedding-004", "supportedGenerationMethods": [ "embedContent", "countTokens", ], }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") # Only gemini-1.5-flash should be included assert len(result) == 1 assert result[0]["value"] == "gemini-1.5-flash" def test_includes_multimethod_models_with_generate(self): """Includes models that support generateContent plus other methods.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "name": "models/gemini-1.5-pro", "supportedGenerationMethods": [ "generateContent", "countTokens", "createCachedContent", ], }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") assert len(result) == 1 assert result[0]["value"] == "gemini-1.5-pro" def test_passes_api_key_via_header_not_url(self): """Passes API key via the ``x-goog-api-key`` header, not as a ``?key=...`` query parameter. See issue #4184: the URL is embedded in ``requests``/``urllib3`` exception messages, but headers are not — so keeping the key out of the URL is the primary prevention-by-construction control against credential leakage through logs. """ mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"models": []} with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response GoogleProvider.list_models_for_api(api_key="my-test-key") url = mock_get.call_args[0][0] headers = mock_get.call_args[1].get("headers") or {} assert "my-test-key" not in url, ( "API key must not appear in the URL — it would leak " "through HTTP exception messages. See issue #4184." ) assert headers.get("x-goog-api-key") == "my-test-key", ( f"API key must be passed via the x-goog-api-key header. " f"Got headers: {headers!r}" ) def test_uses_native_gemini_api_endpoint(self): """Uses native Gemini API endpoint, not OpenAI-compatible.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"models": []} with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response GoogleProvider.list_models_for_api(api_key="test-key") call_args = mock_get.call_args[0] url = call_args[0] # Should use native API, not /openai endpoint assert "generativelanguage.googleapis.com" in url assert "/v1beta/models" in url assert "/openai" not in url def test_returns_empty_on_non_200_status(self): """Returns empty list when API returns non-200 status.""" mock_response = Mock() mock_response.status_code = 401 with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="invalid-key") assert result == [] def test_returns_empty_on_400_status(self): """Returns empty list on 400 Bad Request.""" mock_response = Mock() mock_response.status_code = 400 with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_returns_empty_on_500_status(self): """Returns empty list on server error.""" mock_response = Mock() mock_response.status_code = 500 with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_returns_empty_on_exception(self): """Returns empty list when exception occurs.""" with patch("local_deep_research.security.safe_get") as mock_get: mock_get.side_effect = Exception("Network error") result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_returns_empty_on_connection_error(self): """Returns empty list on connection error.""" import requests with patch("local_deep_research.security.safe_get") as mock_get: mock_get.side_effect = requests.exceptions.ConnectionError() result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_returns_empty_on_timeout(self): """Returns empty list on timeout.""" import requests with patch("local_deep_research.security.safe_get") as mock_get: mock_get.side_effect = requests.exceptions.Timeout() result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_handles_empty_models_list(self): """Handles response with empty models list.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"models": []} with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_handles_missing_models_key(self): """Handles response missing 'models' key.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {} # No 'models' key with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") assert result == [] def test_handles_model_missing_supported_methods(self): """Handles model without supportedGenerationMethods.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "name": "models/gemini-1.5-flash", # Missing supportedGenerationMethods }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") # Model without supportedGenerationMethods should be skipped assert result == [] def test_handles_model_missing_name(self): """Handles model without name field.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "models": [ { "supportedGenerationMethods": ["generateContent"], # Missing 'name' }, ] } with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response result = GoogleProvider.list_models_for_api(api_key="test-key") # Model without name should be skipped assert result == [] def test_ignores_base_url_parameter(self): """Ignores base_url parameter (uses fixed Google endpoint).""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"models": []} with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response # Pass a custom base_url - should be ignored GoogleProvider.list_models_for_api( api_key="test-key", base_url="https://custom-endpoint.com" ) call_args = mock_get.call_args[0] url = call_args[0] # Should still use Google's native API assert "generativelanguage.googleapis.com" in url def test_uses_10_second_timeout(self): """Uses 10 second timeout for API call.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"models": []} with patch("local_deep_research.security.safe_get") as mock_get: mock_get.return_value = mock_response GoogleProvider.list_models_for_api(api_key="test-key") call_kwargs = mock_get.call_args[1] assert call_kwargs["timeout"] == 10 class TestGoogleCreateLLM: """Tests for create_llm method (inherited from base with Google settings).""" 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: GoogleProvider.create_llm() assert "api key" in str(exc_info.value).lower() 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.google.api_key": "test-google-key", "llm.max_tokens": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": 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.openai_base.ChatOpenAI" ) as mock_chat: mock_llm = Mock() mock_chat.return_value = mock_llm result = GoogleProvider.create_llm(model_name="test-model") assert result is mock_llm mock_chat.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.google.api_key": "test-key", "llm.max_tokens": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": 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"): GoogleProvider.create_llm() def test_create_llm_with_custom_model(self): """Uses custom Gemini model when specified.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.google.api_key": "test-key", "llm.max_tokens": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": 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.openai_base.ChatOpenAI" ) as mock_chat: GoogleProvider.create_llm(model_name="gemini-1.5-pro") call_kwargs = mock_chat.call_args[1] assert call_kwargs["model"] == "gemini-1.5-pro" def test_create_llm_uses_google_base_url(self): """Uses Google's OpenAI-compatible endpoint as base URL.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.google.api_key": "test-key", "llm.max_tokens": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": 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.openai_base.ChatOpenAI" ) as mock_chat: with patch( "local_deep_research.llm.providers.openai_base.normalize_url", side_effect=lambda x: x, ): GoogleProvider.create_llm(model_name="test-model") call_kwargs = mock_chat.call_args[1] assert ( "generativelanguage.googleapis.com" in call_kwargs["base_url"] ) def test_create_llm_passes_temperature(self): """Passes temperature parameter.""" def mock_get_setting_side_effect(key, default=None, *args, **kwargs): settings_map = { "llm.google.api_key": "test-key", "llm.max_tokens": None, "llm.streaming": None, "llm.max_retries": None, "llm.request_timeout": 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.openai_base.ChatOpenAI" ) as mock_chat: GoogleProvider.create_llm( model_name="test-model", temperature=0.3 ) call_kwargs = mock_chat.call_args[1] assert call_kwargs["temperature"] == 0.3 class TestGoogleIsAvailable: """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-google-key" result = GoogleProvider.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 = GoogleProvider.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 = GoogleProvider.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 = GoogleProvider.is_available() assert result is False