Mirrowel commited on
Commit
ea15798
·
1 Parent(s): 0132663

feat(auth): enable Gemini CLI stateless authentication via environment variables

Browse files

Implement support for loading Gemini CLI credentials directly from environment variables (e.g., GEMINI_CLI_ACCESS_TOKEN) for stateless hosting environments.

The credential management tool (`credential_tool.py`) now includes a new option to export existing file-based Gemini OAuth credentials into a standard .env file format, simplifying setup for platforms without persistent storage.

- Provider logic is updated to prioritize loading credentials from environment variables over local files.
- Prevents saving newly refreshed access tokens back to the file system when the credentials originated from environment variables.
- Updates README documentation detailing the new tool option and required environment variables.
- Adds `time` import and `Accept` header for robustness.

README.md CHANGED
@@ -119,6 +119,25 @@ For many providers, **no configuration is necessary**. The proxy automatically d
119
 
120
  You only need to create a `.env` file to set your `PROXY_API_KEY` and to override or add credentials if the automatic discovery doesn't suit your needs.
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  **Example `.env` configuration:**
123
  ```env
124
  # A secret key for your proxy server to authenticate requests.
@@ -137,6 +156,16 @@ OPENROUTER_API_KEY_1="YOUR_OPENROUTER_API_KEY_1"
137
  # You can override this by specifying a path to your credential file.
138
  GEMINI_CLI_OAUTH_1="/path/to/your/specific/gemini_creds.json"
139
 
 
 
 
 
 
 
 
 
 
 
140
  # --- Dual Authentication Support ---
141
  # Some providers (qwen_code, iflow) support BOTH OAuth and direct API keys.
142
  # You can use either method, or mix both for credential rotation:
 
119
 
120
  You only need to create a `.env` file to set your `PROXY_API_KEY` and to override or add credentials if the automatic discovery doesn't suit your needs.
121
 
122
+ #### Interactive Credential Management Tool
123
+
124
+ For easier credential management, you can use the interactive credential tool:
125
+
126
+ ```bash
127
+ python -m rotator_library.credential_tool
128
+ ```
129
+
130
+ This tool provides:
131
+ 1. **Add OAuth Credential** - Interactive OAuth flow for Gemini CLI, Qwen Code, and iFlow
132
+ 2. **Add API Key** - Add API keys for any LiteLLM-supported provider
133
+ 3. **Export Gemini CLI to .env** - NEW! Export OAuth credentials to environment variables for stateless deployments
134
+
135
+ **For Stateless Hosting (Railway, Render, Vercel, etc.):**
136
+ - Use option 3 to export your Gemini CLI credentials to `.env` format
137
+ - The generated file contains all necessary environment variables
138
+ - Simply paste these into your hosting platform's environment settings
139
+ - No file persistence required - credentials load automatically from environment variables
140
+
141
  **Example `.env` configuration:**
142
  ```env
143
  # A secret key for your proxy server to authenticate requests.
 
156
  # You can override this by specifying a path to your credential file.
157
  GEMINI_CLI_OAUTH_1="/path/to/your/specific/gemini_creds.json"
158
 
159
+ # --- Gemini CLI: Stateless Deployment Support ---
160
+ # For hosts without file persistence (Railway, Render, etc.), you can provide
161
+ # Gemini CLI credentials directly via environment variables:
162
+ GEMINI_CLI_ACCESS_TOKEN="ya29.your-access-token"
163
+ GEMINI_CLI_REFRESH_TOKEN="1//your-refresh-token"
164
+ GEMINI_CLI_EXPIRY_DATE="1234567890000"
165
+ GEMINI_CLI_EMAIL="your-email@gmail.com"
166
+ # Optional: GEMINI_CLI_PROJECT_ID, GEMINI_CLI_CLIENT_ID, etc.
167
+ # See IMPLEMENTATION_SUMMARY.md for full list of supported variables
168
+
169
  # --- Dual Authentication Support ---
170
  # Some providers (qwen_code, iflow) support BOTH OAuth and direct API keys.
171
  # You can use either method, or mix both for credential rotation:
src/rotator_library/credential_tool.py CHANGED
@@ -3,6 +3,7 @@
3
  import asyncio
4
  import json
5
  import re
 
6
  from pathlib import Path
7
  from dotenv import set_key, get_key
8
 
@@ -220,6 +221,102 @@ async def setup_new_credential(provider_name: str):
220
  console.print(Panel(f"An error occurred during setup for {provider_name}: {e}", style="bold red", title="Error"))
221
 
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  async def main():
224
  """
225
  An interactive CLI tool to add new credentials.
@@ -229,20 +326,20 @@ async def main():
229
 
230
  while True:
231
  console.print(Panel(
232
- Text.from_markup("1. Add OAuth Credential\n2. Add API Key"),
233
  title="Choose credential type",
234
  style="bold blue"
235
  ))
236
-
237
  setup_type = Prompt.ask(
238
  Text.from_markup("[bold]Please select an option or type [red]'q'[/red] to quit[/bold]"),
239
- choices=["1", "2", "q"],
240
  show_choices=False
241
  )
242
 
243
  if setup_type.lower() == 'q':
244
  break
245
-
246
  if setup_type == "1":
247
  available_providers = get_available_providers()
248
  oauth_friendly_names = {
@@ -282,6 +379,9 @@ async def main():
282
  elif setup_type == "2":
283
  await setup_api_key()
284
 
 
 
 
285
  console.print("\n" + "="*50 + "\n")
286
 
287
  def run_credential_tool():
 
3
  import asyncio
4
  import json
5
  import re
6
+ import time
7
  from pathlib import Path
8
  from dotenv import set_key, get_key
9
 
 
221
  console.print(Panel(f"An error occurred during setup for {provider_name}: {e}", style="bold red", title="Error"))
222
 
223
 
224
+ async def export_gemini_cli_to_env():
225
+ """
226
+ Export a Gemini CLI credential JSON file to .env format.
227
+ Generates one .env file per credential.
228
+ """
229
+ console.print(Panel("[bold cyan]Export Gemini CLI Credential to .env[/bold cyan]", expand=False))
230
+
231
+ # Find all gemini_cli credentials
232
+ gemini_cli_files = list(OAUTH_BASE_DIR.glob("gemini_cli_oauth_*.json"))
233
+
234
+ if not gemini_cli_files:
235
+ console.print(Panel("No Gemini CLI credentials found. Please add one first using 'Add OAuth Credential'.",
236
+ style="bold red", title="No Credentials"))
237
+ return
238
+
239
+ # Display available credentials
240
+ cred_text = Text()
241
+ for i, cred_file in enumerate(gemini_cli_files):
242
+ try:
243
+ with open(cred_file, 'r') as f:
244
+ creds = json.load(f)
245
+ email = creds.get("_proxy_metadata", {}).get("email", "unknown")
246
+ cred_text.append(f" {i + 1}. {cred_file.name} ({email})\n")
247
+ except Exception as e:
248
+ cred_text.append(f" {i + 1}. {cred_file.name} (error reading: {e})\n")
249
+
250
+ console.print(Panel(cred_text, title="Available Gemini CLI Credentials", style="bold blue"))
251
+
252
+ choice = Prompt.ask(
253
+ Text.from_markup("[bold]Please select a credential to export or type [red]'b'[/red] to go back[/bold]"),
254
+ choices=[str(i + 1) for i in range(len(gemini_cli_files))] + ["b"],
255
+ show_choices=False
256
+ )
257
+
258
+ if choice.lower() == 'b':
259
+ return
260
+
261
+ try:
262
+ choice_index = int(choice) - 1
263
+ if 0 <= choice_index < len(gemini_cli_files):
264
+ cred_file = gemini_cli_files[choice_index]
265
+
266
+ # Load the credential
267
+ with open(cred_file, 'r') as f:
268
+ creds = json.load(f)
269
+
270
+ # Extract metadata
271
+ email = creds.get("_proxy_metadata", {}).get("email", "unknown")
272
+ project_id = creds.get("_proxy_metadata", {}).get("project_id", "")
273
+
274
+ # Generate .env file name
275
+ safe_email = email.replace("@", "_at_").replace(".", "_")
276
+ env_filename = f"gemini_cli_{safe_email}.env"
277
+ env_filepath = OAUTH_BASE_DIR / env_filename
278
+
279
+ # Build .env content
280
+ env_lines = [
281
+ f"# Gemini CLI Credential for: {email}",
282
+ f"# Generated from: {cred_file.name}",
283
+ f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
284
+ "",
285
+ f"GEMINI_CLI_ACCESS_TOKEN={creds.get('access_token', '')}",
286
+ f"GEMINI_CLI_REFRESH_TOKEN={creds.get('refresh_token', '')}",
287
+ f"GEMINI_CLI_EXPIRY_DATE={creds.get('expiry_date', 0)}",
288
+ f"GEMINI_CLI_CLIENT_ID={creds.get('client_id', '')}",
289
+ f"GEMINI_CLI_CLIENT_SECRET={creds.get('client_secret', '')}",
290
+ f"GEMINI_CLI_TOKEN_URI={creds.get('token_uri', 'https://oauth2.googleapis.com/token')}",
291
+ f"GEMINI_CLI_UNIVERSE_DOMAIN={creds.get('universe_domain', 'googleapis.com')}",
292
+ f"GEMINI_CLI_EMAIL={email}",
293
+ ]
294
+
295
+ # Add project_id if present
296
+ if project_id:
297
+ env_lines.append(f"GEMINI_CLI_PROJECT_ID={project_id}")
298
+
299
+ # Write to .env file
300
+ with open(env_filepath, 'w') as f:
301
+ f.write('\n'.join(env_lines))
302
+
303
+ success_text = Text.from_markup(
304
+ f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
305
+ f"To use this credential:\n"
306
+ f"1. Copy [bold yellow]{env_filepath.name}[/bold yellow] to your deployment environment\n"
307
+ f"2. Load the variables: [bold cyan]export $(cat {env_filepath.name} | grep -v '^#' | xargs)[/bold cyan]\n"
308
+ f"3. Or source it: [bold cyan]source {env_filepath.name}[/bold cyan]\n"
309
+ f"4. The Gemini CLI provider will automatically use these environment variables"
310
+ )
311
+ console.print(Panel(success_text, style="bold green", title="Success"))
312
+ else:
313
+ console.print("[bold red]Invalid choice. Please try again.[/bold red]")
314
+ except ValueError:
315
+ console.print("[bold red]Invalid input. Please enter a number or 'b'.[/bold red]")
316
+ except Exception as e:
317
+ console.print(Panel(f"An error occurred during export: {e}", style="bold red", title="Error"))
318
+
319
+
320
  async def main():
321
  """
322
  An interactive CLI tool to add new credentials.
 
326
 
327
  while True:
328
  console.print(Panel(
329
+ Text.from_markup("1. Add OAuth Credential\n2. Add API Key\n3. Export Gemini CLI credential to .env"),
330
  title="Choose credential type",
331
  style="bold blue"
332
  ))
333
+
334
  setup_type = Prompt.ask(
335
  Text.from_markup("[bold]Please select an option or type [red]'q'[/red] to quit[/bold]"),
336
+ choices=["1", "2", "3", "q"],
337
  show_choices=False
338
  )
339
 
340
  if setup_type.lower() == 'q':
341
  break
342
+
343
  if setup_type == "1":
344
  available_providers = get_available_providers()
345
  oauth_friendly_names = {
 
379
  elif setup_type == "2":
380
  await setup_api_key()
381
 
382
+ elif setup_type == "3":
383
+ await export_gemini_cli_to_env()
384
+
385
  console.print("\n" + "="*50 + "\n")
386
 
387
  def run_credential_tool():
src/rotator_library/providers/gemini_auth_base.py CHANGED
@@ -1,7 +1,8 @@
1
  # src/rotator_library/providers/gemini_auth_base.py
2
 
 
3
  import webbrowser
4
- from typing import Union
5
  import json
6
  import time
7
  import asyncio
@@ -29,13 +30,80 @@ class GeminiAuthBase:
29
  self._credentials_cache: Dict[str, Dict[str, Any]] = {}
30
  self._refresh_locks: Dict[str, asyncio.Lock] = {}
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  async def _load_credentials(self, path: str) -> Dict[str, Any]:
33
  if path in self._credentials_cache:
34
  return self._credentials_cache[path]
35
-
36
  async with self._get_lock(path):
37
  if path in self._credentials_cache:
38
  return self._credentials_cache[path]
 
 
 
 
 
 
 
 
 
 
39
  try:
40
  lib_logger.debug(f"Loading Gemini credentials from file: {path}")
41
  with open(path, 'r') as f:
@@ -52,6 +120,12 @@ class GeminiAuthBase:
52
 
53
  async def _save_credentials(self, path: str, creds: Dict[str, Any]):
54
  self._credentials_cache[path] = creds
 
 
 
 
 
 
55
  try:
56
  with open(path, 'w') as f:
57
  json.dump(creds, f, indent=2)
 
1
  # src/rotator_library/providers/gemini_auth_base.py
2
 
3
+ import os
4
  import webbrowser
5
+ from typing import Union, Optional
6
  import json
7
  import time
8
  import asyncio
 
30
  self._credentials_cache: Dict[str, Dict[str, Any]] = {}
31
  self._refresh_locks: Dict[str, asyncio.Lock] = {}
32
 
33
+ def _load_from_env(self) -> Optional[Dict[str, Any]]:
34
+ """
35
+ Load OAuth credentials from environment variables for stateless deployments.
36
+
37
+ Expected environment variables:
38
+ - GEMINI_CLI_ACCESS_TOKEN (required)
39
+ - GEMINI_CLI_REFRESH_TOKEN (required)
40
+ - GEMINI_CLI_EXPIRY_DATE (optional, defaults to 0)
41
+ - GEMINI_CLI_CLIENT_ID (optional, uses default)
42
+ - GEMINI_CLI_CLIENT_SECRET (optional, uses default)
43
+ - GEMINI_CLI_TOKEN_URI (optional, uses default)
44
+ - GEMINI_CLI_UNIVERSE_DOMAIN (optional, defaults to googleapis.com)
45
+ - GEMINI_CLI_EMAIL (optional, defaults to "env-user")
46
+ - GEMINI_CLI_PROJECT_ID (optional)
47
+
48
+ Returns:
49
+ Dict with credential structure if env vars present, None otherwise
50
+ """
51
+ access_token = os.getenv("GEMINI_CLI_ACCESS_TOKEN")
52
+ refresh_token = os.getenv("GEMINI_CLI_REFRESH_TOKEN")
53
+
54
+ # Both access and refresh tokens are required
55
+ if not (access_token and refresh_token):
56
+ return None
57
+
58
+ lib_logger.debug("Loading Gemini CLI credentials from environment variables")
59
+
60
+ # Parse expiry_date as float, default to 0 if not present
61
+ expiry_str = os.getenv("GEMINI_CLI_EXPIRY_DATE", "0")
62
+ try:
63
+ expiry_date = float(expiry_str)
64
+ except ValueError:
65
+ lib_logger.warning(f"Invalid GEMINI_CLI_EXPIRY_DATE value: {expiry_str}, using 0")
66
+ expiry_date = 0
67
+
68
+ creds = {
69
+ "access_token": access_token,
70
+ "refresh_token": refresh_token,
71
+ "expiry_date": expiry_date,
72
+ "client_id": os.getenv("GEMINI_CLI_CLIENT_ID", CLIENT_ID),
73
+ "client_secret": os.getenv("GEMINI_CLI_CLIENT_SECRET", CLIENT_SECRET),
74
+ "token_uri": os.getenv("GEMINI_CLI_TOKEN_URI", TOKEN_URI),
75
+ "universe_domain": os.getenv("GEMINI_CLI_UNIVERSE_DOMAIN", "googleapis.com"),
76
+ "_proxy_metadata": {
77
+ "email": os.getenv("GEMINI_CLI_EMAIL", "env-user"),
78
+ "last_check_timestamp": time.time(),
79
+ "loaded_from_env": True # Flag to indicate env-based credentials
80
+ }
81
+ }
82
+
83
+ # Add project_id if provided
84
+ project_id = os.getenv("GEMINI_CLI_PROJECT_ID")
85
+ if project_id:
86
+ creds["_proxy_metadata"]["project_id"] = project_id
87
+
88
+ return creds
89
+
90
  async def _load_credentials(self, path: str) -> Dict[str, Any]:
91
  if path in self._credentials_cache:
92
  return self._credentials_cache[path]
93
+
94
  async with self._get_lock(path):
95
  if path in self._credentials_cache:
96
  return self._credentials_cache[path]
97
+
98
+ # First, try loading from environment variables
99
+ env_creds = self._load_from_env()
100
+ if env_creds:
101
+ lib_logger.info("Using Gemini CLI credentials from environment variables")
102
+ # Cache env-based credentials using the path as key
103
+ self._credentials_cache[path] = env_creds
104
+ return env_creds
105
+
106
+ # Fall back to file-based loading
107
  try:
108
  lib_logger.debug(f"Loading Gemini credentials from file: {path}")
109
  with open(path, 'r') as f:
 
120
 
121
  async def _save_credentials(self, path: str, creds: Dict[str, Any]):
122
  self._credentials_cache[path] = creds
123
+
124
+ # Don't save to file if credentials were loaded from environment
125
+ if creds.get("_proxy_metadata", {}).get("loaded_from_env"):
126
+ lib_logger.debug("Credentials loaded from env, skipping file save")
127
+ return
128
+
129
  try:
130
  with open(path, 'w') as f:
131
  json.dump(creds, f, indent=2)
src/rotator_library/providers/gemini_cli_provider.py CHANGED
@@ -596,6 +596,7 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
596
  "User-Agent": "google-api-nodejs-client/9.15.1",
597
  "X-Goog-Api-Client": "gl-node/22.17.0",
598
  "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
 
599
  })
600
  try:
601
  async with client.stream("POST", url, headers=final_headers, json=request_payload, params={"alt": "sse"}, timeout=600) as response:
 
596
  "User-Agent": "google-api-nodejs-client/9.15.1",
597
  "X-Goog-Api-Client": "gl-node/22.17.0",
598
  "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
599
+ "Accept": "application/json",
600
  })
601
  try:
602
  async with client.stream("POST", url, headers=final_headers, json=request_payload, params={"alt": "sse"}, timeout=600) as response: