Coverage for src/local_deep_research/llm/providers/implementations/google.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Google/Gemini LLM provider for Local Deep Research.""" 

2 

3from ....security.secure_logging import logger 

4 

5from ....security.log_sanitizer import redact_secrets 

6from ..base import Exposure 

7from ..openai_base import OpenAICompatibleProvider 

8 

9 

10class GoogleProvider(OpenAICompatibleProvider): 

11 """Google Gemini provider using OpenAI-compatible endpoint. 

12 

13 This uses Google's OpenAI-compatible API endpoint to access Gemini models, 

14 which automatically supports all current and future Gemini models without 

15 needing to update the code. 

16 """ 

17 

18 provider_name = "Google Gemini" 

19 api_key_setting = "llm.google.api_key" 

20 default_base_url = "https://generativelanguage.googleapis.com/v1beta/openai" 

21 default_model = "" # User must explicitly pick a model — no silent fallback 

22 

23 # Metadata for auto-discovery 

24 provider_key = "GOOGLE" 

25 company_name = "Google" 

26 is_cloud = True 

27 # Egress exposure (ADR-0007): cloud inference sink — data leaves the box. 

28 egress_exposure = Exposure.EXPOSING 

29 

30 @classmethod 

31 def requires_auth_for_models(cls): 

32 """Google requires authentication for listing models. 

33 

34 Note: Google's OpenAI-compatible /models endpoint has a bug (returns 401). 

35 The native Gemini API endpoint requires an API key. 

36 """ 

37 return True 

38 

39 @classmethod 

40 def list_models_for_api(cls, api_key=None, base_url=None): 

41 """List available models using Google's native API. 

42 

43 Args: 

44 api_key: Google API key 

45 base_url: Not used - Google uses a fixed endpoint 

46 

47 Google's OpenAI-compatible /models endpoint returns 401 (bug), 

48 so we use the native Gemini API endpoint instead. 

49 """ 

50 # Trim a pasted key the way create_llm's resolve_api_key does. 

51 # Without this a key with surrounding whitespace works for research 

52 # but fails model discovery with a bare auth error, and a 

53 # whitespace-only key is truthy so it is still sent. 

54 if isinstance(api_key, str): 

55 api_key = api_key.strip() 

56 

57 if not api_key: 

58 logger.debug("Google Gemini requires API key for listing models") 

59 return [] 

60 

61 try: 

62 from ....security import safe_get 

63 

64 # Use the native Gemini API endpoint (not OpenAI-compatible). 

65 # Pass the key via the x-goog-api-key header rather than a 

66 # ?key=... query parameter so the secret never appears in the 

67 # request URL — and therefore cannot leak into requests / 

68 # urllib3 exception messages, which embed the URL but not 

69 # headers. This is the prevention-by-construction control 

70 # called for in issue #4184; the redact_secrets() wrap below 

71 # remains as defense-in-depth. 

72 url = "https://generativelanguage.googleapis.com/v1beta/models" 

73 

74 response = safe_get( 

75 url, 

76 headers={"x-goog-api-key": api_key}, 

77 timeout=10, 

78 ) 

79 

80 if response.status_code == 200: 

81 data = response.json() 

82 models = [] 

83 

84 for model in data.get("models", []): 

85 model_name = model.get("name", "") 

86 # Extract just the model ID from "models/gemini-1.5-flash" 

87 if model_name.startswith("models/"): 

88 model_id = model_name[7:] # Remove "models/" prefix 

89 else: 

90 model_id = model_name 

91 

92 # Only include generative models (not embedding models) 

93 supported_methods = model.get( 

94 "supportedGenerationMethods", [] 

95 ) 

96 if "generateContent" in supported_methods and model_id: 

97 models.append( 

98 { 

99 "value": model_id, 

100 "label": model_id, 

101 } 

102 ) 

103 

104 logger.info( 

105 f"Found {len(models)} generative models from Google Gemini API" 

106 ) 

107 return models 

108 logger.warning( 

109 f"Google Gemini API returned status {response.status_code}" 

110 ) 

111 return [] 

112 

113 except Exception as e: 

114 # Defense-in-depth: the primary defense is now the 

115 # x-goog-api-key header above (prevention by construction — 

116 # the URL no longer carries the key, so requests/urllib3 

117 # exception messages cannot embed it). This redact_secrets() 

118 # wrap stays as a second layer in case a future code path 

119 # reintroduces the key into a logged string, and we keep 

120 # logger.warning rather than logger.exception so the 

121 # exception chain (which carries the URL in earlier frames) 

122 # is dropped. The redacted message is captured in a local 

123 # before the logger call so the check-sensitive-logging 

124 # pre-commit hook does not flag the exception variable as 

125 # referenced inside the log call. 

126 safe_msg = redact_secrets(str(e), api_key) 

127 logger.warning(f"Error fetching Google Gemini models: {safe_msg}") 

128 return []