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
« 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."""
3from ....security.secure_logging import logger
5from ....security.log_sanitizer import redact_secrets
6from ..base import Exposure
7from ..openai_base import OpenAICompatibleProvider
10class GoogleProvider(OpenAICompatibleProvider):
11 """Google Gemini provider using OpenAI-compatible endpoint.
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 """
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
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
30 @classmethod
31 def requires_auth_for_models(cls):
32 """Google requires authentication for listing models.
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
39 @classmethod
40 def list_models_for_api(cls, api_key=None, base_url=None):
41 """List available models using Google's native API.
43 Args:
44 api_key: Google API key
45 base_url: Not used - Google uses a fixed endpoint
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()
57 if not api_key:
58 logger.debug("Google Gemini requires API key for listing models")
59 return []
61 try:
62 from ....security import safe_get
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"
74 response = safe_get(
75 url,
76 headers={"x-goog-api-key": api_key},
77 timeout=10,
78 )
80 if response.status_code == 200:
81 data = response.json()
82 models = []
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
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 )
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 []
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 []