Mirrowel commited on
Commit
5bc49f2
·
1 Parent(s): 6c4ca7c

feat(auth): ✨ add environment variable-based OAuth credential support with multi-account capability

Browse files

Introduces a comprehensive environment variable-based credential system for stateless deployments, enabling multiple OAuth accounts per provider without requiring credential files.

Key changes:

- Add env-based credential discovery in CredentialManager with priority over file-based credentials
- Implement numbered credential format (PROVIDER_N_ACCESS_TOKEN) supporting multiple accounts per provider
- Support legacy single-credential format (PROVIDER_ACCESS_TOKEN) for backwards compatibility
- Introduce virtual path system (env://provider/index) for env-based credentials
- Update credential export tool to generate numbered .env files with merge instructions
- Extend env credential support across all OAuth providers (Google OAuth, Antigravity, iFlow, Qwen Code)
- Add Windows launcher script (launcher.bat) with interactive menu system for proxy configuration

The numbered format allows combining multiple credentials in a single .env file:
- ANTIGRAVITY_1_ACCESS_TOKEN, ANTIGRAVITY_1_REFRESH_TOKEN (first account)
- ANTIGRAVITY_2_ACCESS_TOKEN, ANTIGRAVITY_2_REFRESH_TOKEN (second account)
- etc.

This enables containerized and serverless deployments without managing credential files, while maintaining full multi-account rotation capabilities.

launcher.bat ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ :: ================================================================================
3
+ :: Universal Instructions for macOS / Linux Users
4
+ :: ================================================================================
5
+ :: This launcher.bat file is for Windows only.
6
+ :: If you are on macOS or Linux, please use the following Python commands directly
7
+ :: in your terminal.
8
+ ::
9
+ :: First, ensure you have Python 3.10 or higher installed.
10
+ ::
11
+ :: To run the proxy server (basic command):
12
+ :: export PYTHONPATH=${PYTHONPATH}:$(pwd)/src
13
+ :: python src/proxy_app/main.py --host 0.0.0.0 --port 8000
14
+ ::
15
+ :: Note: To enable request logging, add the --enable-request-logging flag to the command.
16
+ ::
17
+ :: To add new credentials:
18
+ :: export PYTHONPATH=${PYTHONPATH}:$(pwd)/src
19
+ :: python src/proxy_app/main.py --add-credential
20
+ ::
21
+ :: To build the executable (requires PyInstaller):
22
+ :: pip install -r requirements.txt
23
+ :: pip install pyinstaller
24
+ :: python src/proxy_app/build.py
25
+ :: ================================================================================
26
+
27
+ setlocal enabledelayedexpansion
28
+
29
+ :: Default Settings
30
+ set "HOST=0.0.0.0"
31
+ set "PORT=8000"
32
+ set "LOGGING=false"
33
+ set "EXECUTION_MODE="
34
+ set "EXE_NAME=proxy_app.exe"
35
+ set "SOURCE_PATH=src\proxy_app\main.py"
36
+
37
+ :: --- Phase 1: Detection and Mode Selection ---
38
+ set "EXE_EXISTS=false"
39
+ set "SOURCE_EXISTS=false"
40
+
41
+ if exist "%EXE_NAME%" (
42
+ set "EXE_EXISTS=true"
43
+ )
44
+
45
+ if exist "%SOURCE_PATH%" (
46
+ set "SOURCE_EXISTS=true"
47
+ )
48
+
49
+ if "%EXE_EXISTS%"=="true" (
50
+ if "%SOURCE_EXISTS%"=="true" (
51
+ call :SelectModeMenu
52
+ ) else (
53
+ set "EXECUTION_MODE=exe"
54
+ )
55
+ ) else (
56
+ if "%SOURCE_EXISTS%"=="true" (
57
+ set "EXECUTION_MODE=source"
58
+ call :CheckPython
59
+ if errorlevel 1 goto :eof
60
+ ) else (
61
+ call :NoTargetsFound
62
+ )
63
+ )
64
+
65
+ if "%EXECUTION_MODE%"=="" (
66
+ goto :eof
67
+ )
68
+
69
+ :: --- Phase 2: Main Menu ---
70
+ :MainMenu
71
+ cls
72
+ echo ==================================================
73
+ echo LLM API Key Proxy Launcher
74
+ echo ==================================================
75
+ echo.
76
+ echo Current Configuration:
77
+ echo ----------------------
78
+ echo - Host IP: %HOST%
79
+ echo - Port: %PORT%
80
+ echo - Request Logging: %LOGGING%
81
+ echo - Execution Mode: %EXECUTION_MODE%
82
+ echo.
83
+ echo Main Menu:
84
+ echo ----------
85
+ echo 1. Run Proxy
86
+ echo 2. Configure Proxy
87
+ echo 3. Add Credentials
88
+ if "%EXECUTION_MODE%"=="source" (
89
+ echo 4. Build Executable
90
+ echo 5. Exit
91
+ ) else (
92
+ echo 4. Exit
93
+ )
94
+ echo.
95
+ set /p "CHOICE=Enter your choice: "
96
+
97
+ if "%CHOICE%"=="1" goto :RunProxy
98
+ if "%CHOICE%"=="2" goto :ConfigMenu
99
+ if "%CHOICE%"=="3" goto :AddCredentials
100
+
101
+ if "%EXECUTION_MODE%"=="source" (
102
+ if "%CHOICE%"=="4" goto :BuildExecutable
103
+ if "%CHOICE%"=="5" goto :eof
104
+ ) else (
105
+ if "%CHOICE%"=="4" goto :eof
106
+ )
107
+
108
+ echo Invalid choice.
109
+ pause
110
+ goto :MainMenu
111
+
112
+ :: --- Phase 3: Configuration Sub-Menu ---
113
+ :ConfigMenu
114
+ cls
115
+ echo ==================================================
116
+ echo Configuration Menu
117
+ echo ==================================================
118
+ echo.
119
+ echo Current Configuration:
120
+ echo ----------------------
121
+ echo - Host IP: %HOST%
122
+ echo - Port: %PORT%
123
+ echo - Request Logging: %LOGGING%
124
+ echo - Execution Mode: %EXECUTION_MODE%
125
+ echo.
126
+ echo Configuration Options:
127
+ echo ----------------------
128
+ echo 1. Set Host IP
129
+ echo 2. Set Port
130
+ echo 3. Toggle Request Logging
131
+ echo 4. Back to Main Menu
132
+ echo.
133
+ set /p "CHOICE=Enter your choice: "
134
+
135
+ if "%CHOICE%"=="1" (
136
+ set /p "NEW_HOST=Enter new Host IP: "
137
+ if defined NEW_HOST (
138
+ set "HOST=!NEW_HOST!"
139
+ )
140
+ goto :ConfigMenu
141
+ )
142
+ if "%CHOICE%"=="2" (
143
+ set "NEW_PORT="
144
+ set /p "NEW_PORT=Enter new Port: "
145
+ if not defined NEW_PORT goto :ConfigMenu
146
+ set "IS_NUM=true"
147
+ for /f "delims=0123456789" %%i in ("!NEW_PORT!") do set "IS_NUM=false"
148
+ if "!IS_NUM!"=="false" (
149
+ echo Invalid Port. Please enter numbers only.
150
+ pause
151
+ ) else (
152
+ if !NEW_PORT! GTR 65535 (
153
+ echo Invalid Port. Port cannot be greater than 65535.
154
+ pause
155
+ ) else (
156
+ set "PORT=!NEW_PORT!"
157
+ )
158
+ )
159
+ goto :ConfigMenu
160
+ )
161
+ if "%CHOICE%"=="3" (
162
+ if "%LOGGING%"=="true" (
163
+ set "LOGGING=false"
164
+ ) else (
165
+ set "LOGGING=true"
166
+ )
167
+ goto :ConfigMenu
168
+ )
169
+ if "%CHOICE%"=="4" goto :MainMenu
170
+
171
+ echo Invalid choice.
172
+ pause
173
+ goto :ConfigMenu
174
+
175
+ :: --- Phase 4: Execution ---
176
+ :RunProxy
177
+ cls
178
+ set "ARGS=--host "%HOST%" --port %PORT%"
179
+ if "%LOGGING%"=="true" (
180
+ set "ARGS=%ARGS% --enable-request-logging"
181
+ )
182
+ echo Starting Proxy...
183
+ echo Arguments: %ARGS%
184
+ echo.
185
+ if "%EXECUTION_MODE%"=="exe" (
186
+ start "LLM API Proxy" "%EXE_NAME%" %ARGS%
187
+ ) else (
188
+ set "PYTHONPATH=%~dp0src;%PYTHONPATH%"
189
+ start "LLM API Proxy" python "%SOURCE_PATH%" %ARGS%
190
+ )
191
+ exit /b 0
192
+
193
+ :AddCredentials
194
+ cls
195
+ echo Launching Credential Tool...
196
+ echo.
197
+ if "%EXECUTION_MODE%"=="exe" (
198
+ "%EXE_NAME%" --add-credential
199
+ ) else (
200
+ set "PYTHONPATH=%~dp0src;%PYTHONPATH%"
201
+ python "%SOURCE_PATH%" --add-credential
202
+ )
203
+ pause
204
+ goto :MainMenu
205
+
206
+ :BuildExecutable
207
+ cls
208
+ echo ==================================================
209
+ echo Building Executable
210
+ echo ==================================================
211
+ echo.
212
+ echo The build process will start in a new window.
213
+ start "Build Process" cmd /c "pip install -r requirements.txt && pip install pyinstaller && python "src/proxy_app/build.py" && echo Build finished. && pause"
214
+ exit /b
215
+
216
+ :: --- Helper Functions ---
217
+
218
+ :SelectModeMenu
219
+ cls
220
+ echo ==================================================
221
+ echo Execution Mode Selection
222
+ echo ==================================================
223
+ echo.
224
+ echo Both executable and source code found.
225
+ echo Please choose which to use:
226
+ echo.
227
+ echo 1. Executable ("%EXE_NAME%")
228
+ echo 2. Source Code ("%SOURCE_PATH%")
229
+ echo.
230
+ set /p "CHOICE=Enter your choice: "
231
+
232
+ if "%CHOICE%"=="1" (
233
+ set "EXECUTION_MODE=exe"
234
+ ) else if "%CHOICE%"=="2" (
235
+ call :CheckPython
236
+ if errorlevel 1 goto :eof
237
+ set "EXECUTION_MODE=source"
238
+ ) else (
239
+ echo Invalid choice.
240
+ pause
241
+ goto :SelectModeMenu
242
+ )
243
+ goto :end_of_function
244
+
245
+ :CheckPython
246
+ where python >nul 2>nul
247
+ if errorlevel 1 (
248
+ echo Error: Python is not installed or not in PATH.
249
+ echo Please install Python and try again.
250
+ pause
251
+ exit /b 1
252
+ )
253
+
254
+ for /f "tokens=1,2" %%a in ('python -c "import sys; print(sys.version_info.major, sys.version_info.minor)"') do (
255
+ set "PY_MAJOR=%%a"
256
+ set "PY_MINOR=%%b"
257
+ )
258
+
259
+ if not "%PY_MAJOR%"=="3" (
260
+ call :PythonVersionError
261
+ exit /b 1
262
+ )
263
+ if %PY_MINOR% lss 10 (
264
+ call :PythonVersionError
265
+ exit /b 1
266
+ )
267
+
268
+ exit /b 0
269
+
270
+ :PythonVersionError
271
+ echo Error: Python 3.10 or higher is required.
272
+ echo Found version: %PY_MAJOR%.%PY_MINOR%
273
+ echo Please upgrade your Python installation.
274
+ pause
275
+ goto :eof
276
+
277
+ :NoTargetsFound
278
+ cls
279
+ echo ==================================================
280
+ echo Error
281
+ echo ==================================================
282
+ echo.
283
+ echo Could not find the executable ("%EXE_NAME%")
284
+ echo or the source code ("%SOURCE_PATH%").
285
+ echo.
286
+ echo Please ensure the launcher is in the correct
287
+ echo directory or that the project has been built.
288
+ echo.
289
+ pause
290
+ goto :eof
291
+
292
+ :end_of_function
293
+ endlocal
src/rotator_library/credential_manager.py CHANGED
@@ -1,8 +1,9 @@
1
  import os
 
2
  import shutil
3
  import logging
4
  from pathlib import Path
5
- from typing import Dict, List, Optional
6
 
7
  lib_logger = logging.getLogger('rotator_library')
8
 
@@ -18,19 +19,96 @@ DEFAULT_OAUTH_DIRS = {
18
  # Add other providers like 'claude' here if they have a standard CLI path
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
21
  class CredentialManager:
22
  """
23
  Discovers OAuth credential files from standard locations, copies them locally,
24
  and updates the configuration to use the local paths.
 
 
 
 
 
 
 
 
25
  """
26
  def __init__(self, env_vars: Dict[str, str]):
27
  self.env_vars = env_vars
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def discover_and_prepare(self) -> Dict[str, List[str]]:
30
  lib_logger.info("Starting automated OAuth credential discovery...")
31
  final_config = {}
32
 
33
- # Extract OAuth paths from environment variables first
 
 
 
 
 
 
 
34
  env_oauth_paths = {}
35
  for key, value in self.env_vars.items():
36
  if "_OAUTH_" in key:
@@ -40,7 +118,13 @@ class CredentialManager:
40
  if value: # Only consider non-empty values
41
  env_oauth_paths[provider].append(value)
42
 
 
43
  for provider, default_dir in DEFAULT_OAUTH_DIRS.items():
 
 
 
 
 
44
  # Check for existing local credentials first. If found, use them and skip discovery.
45
  local_provider_creds = sorted(list(OAUTH_BASE_DIR.glob(f"{provider}_oauth_*.json")))
46
  if local_provider_creds:
 
1
  import os
2
+ import re
3
  import shutil
4
  import logging
5
  from pathlib import Path
6
+ from typing import Dict, List, Optional, Set
7
 
8
  lib_logger = logging.getLogger('rotator_library')
9
 
 
19
  # Add other providers like 'claude' here if they have a standard CLI path
20
  }
21
 
22
+ # OAuth providers that support environment variable-based credentials
23
+ # Maps provider name to the ENV_PREFIX used by the provider
24
+ ENV_OAUTH_PROVIDERS = {
25
+ "gemini_cli": "GEMINI_CLI",
26
+ "antigravity": "ANTIGRAVITY",
27
+ "qwen_code": "QWEN_CODE",
28
+ "iflow": "IFLOW",
29
+ }
30
+
31
+
32
  class CredentialManager:
33
  """
34
  Discovers OAuth credential files from standard locations, copies them locally,
35
  and updates the configuration to use the local paths.
36
+
37
+ Also discovers environment variable-based OAuth credentials for stateless deployments.
38
+ Supports two env var formats:
39
+
40
+ 1. Single credential (legacy): PROVIDER_ACCESS_TOKEN, PROVIDER_REFRESH_TOKEN
41
+ 2. Multiple credentials (numbered): PROVIDER_1_ACCESS_TOKEN, PROVIDER_2_ACCESS_TOKEN, etc.
42
+
43
+ When env-based credentials are detected, virtual paths like "env://provider/1" are created.
44
  """
45
  def __init__(self, env_vars: Dict[str, str]):
46
  self.env_vars = env_vars
47
 
48
+ def _discover_env_oauth_credentials(self) -> Dict[str, List[str]]:
49
+ """
50
+ Discover OAuth credentials defined via environment variables.
51
+
52
+ Supports two formats:
53
+ 1. Single credential: ANTIGRAVITY_ACCESS_TOKEN + ANTIGRAVITY_REFRESH_TOKEN
54
+ 2. Multiple credentials: ANTIGRAVITY_1_ACCESS_TOKEN + ANTIGRAVITY_1_REFRESH_TOKEN, etc.
55
+
56
+ Returns:
57
+ Dict mapping provider name to list of virtual paths (e.g., "env://antigravity/1")
58
+ """
59
+ env_credentials: Dict[str, Set[str]] = {}
60
+
61
+ for provider, env_prefix in ENV_OAUTH_PROVIDERS.items():
62
+ found_indices: Set[str] = set()
63
+
64
+ # Check for numbered credentials (PROVIDER_N_ACCESS_TOKEN pattern)
65
+ # Pattern: ANTIGRAVITY_1_ACCESS_TOKEN, ANTIGRAVITY_2_ACCESS_TOKEN, etc.
66
+ numbered_pattern = re.compile(rf"^{env_prefix}_(\d+)_ACCESS_TOKEN$")
67
+
68
+ for key in self.env_vars.keys():
69
+ match = numbered_pattern.match(key)
70
+ if match:
71
+ index = match.group(1)
72
+ # Verify refresh token also exists
73
+ refresh_key = f"{env_prefix}_{index}_REFRESH_TOKEN"
74
+ if refresh_key in self.env_vars and self.env_vars[refresh_key]:
75
+ found_indices.add(index)
76
+
77
+ # Check for legacy single credential (PROVIDER_ACCESS_TOKEN pattern)
78
+ # Only use this if no numbered credentials exist
79
+ if not found_indices:
80
+ access_key = f"{env_prefix}_ACCESS_TOKEN"
81
+ refresh_key = f"{env_prefix}_REFRESH_TOKEN"
82
+ if (access_key in self.env_vars and self.env_vars[access_key] and
83
+ refresh_key in self.env_vars and self.env_vars[refresh_key]):
84
+ # Use "0" as the index for legacy single credential
85
+ found_indices.add("0")
86
+
87
+ if found_indices:
88
+ env_credentials[provider] = found_indices
89
+ lib_logger.info(f"Found {len(found_indices)} env-based credential(s) for {provider}")
90
+
91
+ # Convert to virtual paths
92
+ result: Dict[str, List[str]] = {}
93
+ for provider, indices in env_credentials.items():
94
+ # Sort indices numerically for consistent ordering
95
+ sorted_indices = sorted(indices, key=lambda x: int(x))
96
+ result[provider] = [f"env://{provider}/{idx}" for idx in sorted_indices]
97
+
98
+ return result
99
+
100
  def discover_and_prepare(self) -> Dict[str, List[str]]:
101
  lib_logger.info("Starting automated OAuth credential discovery...")
102
  final_config = {}
103
 
104
+ # PHASE 1: Discover environment variable-based OAuth credentials
105
+ # These take priority for stateless deployments
106
+ env_oauth_creds = self._discover_env_oauth_credentials()
107
+ for provider, virtual_paths in env_oauth_creds.items():
108
+ lib_logger.info(f"Using {len(virtual_paths)} env-based credential(s) for {provider}")
109
+ final_config[provider] = virtual_paths
110
+
111
+ # Extract OAuth file paths from environment variables
112
  env_oauth_paths = {}
113
  for key, value in self.env_vars.items():
114
  if "_OAUTH_" in key:
 
118
  if value: # Only consider non-empty values
119
  env_oauth_paths[provider].append(value)
120
 
121
+ # PHASE 2: Discover file-based OAuth credentials
122
  for provider, default_dir in DEFAULT_OAUTH_DIRS.items():
123
+ # Skip if already discovered from environment variables
124
+ if provider in final_config:
125
+ lib_logger.debug(f"Skipping file discovery for {provider} - using env-based credentials")
126
+ continue
127
+
128
  # Check for existing local credentials first. If found, use them and skip discovery.
129
  local_provider_creds = sorted(list(OAUTH_BASE_DIR.glob(f"{provider}_oauth_*.json")))
130
  if local_provider_creds:
src/rotator_library/credential_tool.py CHANGED
@@ -36,6 +36,77 @@ def _ensure_providers_loaded():
36
  _provider_plugins = pp
37
  return _provider_factory, _provider_plugins
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def ensure_env_defaults():
40
  """
41
  Ensures the .env file exists and contains essential default values like PROXY_API_KEY.
@@ -256,12 +327,12 @@ async def setup_new_credential(provider_name: str):
256
  async def export_gemini_cli_to_env():
257
  """
258
  Export a Gemini CLI credential JSON file to .env format.
259
- Generates one .env file per credential.
260
  """
261
  console.print(Panel("[bold cyan]Export Gemini CLI Credential to .env[/bold cyan]", expand=False))
262
 
263
  # Find all gemini_cli credentials
264
- gemini_cli_files = list(OAUTH_BASE_DIR.glob("gemini_cli_oauth_*.json"))
265
 
266
  if not gemini_cli_files:
267
  console.print(Panel("No Gemini CLI credentials found. Please add one first using 'Add OAuth Credential'.",
@@ -304,34 +375,30 @@ async def export_gemini_cli_to_env():
304
  project_id = creds.get("_proxy_metadata", {}).get("project_id", "")
305
  tier = creds.get("_proxy_metadata", {}).get("tier", "")
306
 
307
- # Generate .env file name
 
 
 
308
  safe_email = email.replace("@", "_at_").replace(".", "_")
309
- env_filename = f"gemini_cli_{safe_email}.env"
310
  env_filepath = OAUTH_BASE_DIR / env_filename
311
 
312
- # Build .env content
313
- env_lines = [
314
- f"# Gemini CLI Credential for: {email}",
315
- f"# Generated from: {cred_file.name}",
316
- f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
317
- "",
318
- f"GEMINI_CLI_ACCESS_TOKEN={creds.get('access_token', '')}",
319
- f"GEMINI_CLI_REFRESH_TOKEN={creds.get('refresh_token', '')}",
320
- f"GEMINI_CLI_EXPIRY_DATE={creds.get('expiry_date', 0)}",
321
- f"GEMINI_CLI_CLIENT_ID={creds.get('client_id', '')}",
322
- f"GEMINI_CLI_CLIENT_SECRET={creds.get('client_secret', '')}",
323
- f"GEMINI_CLI_TOKEN_URI={creds.get('token_uri', 'https://oauth2.googleapis.com/token')}",
324
- f"GEMINI_CLI_UNIVERSE_DOMAIN={creds.get('universe_domain', 'googleapis.com')}",
325
- f"GEMINI_CLI_EMAIL={email}",
326
- ]
327
-
328
- # Add project_id if present
329
  if project_id:
330
- env_lines.append(f"GEMINI_CLI_PROJECT_ID={project_id}")
331
-
332
- # Add tier if present
333
  if tier:
334
- env_lines.append(f"GEMINI_CLI_TIER={tier}")
 
 
 
 
 
 
 
 
 
 
335
 
336
  # Write to .env file
337
  with open(env_filepath, 'w') as f:
@@ -339,11 +406,14 @@ async def export_gemini_cli_to_env():
339
 
340
  success_text = Text.from_markup(
341
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
342
- f"To use this credential:\n"
343
- f"1. Copy [bold yellow]{env_filepath.name}[/bold yellow] to your deployment environment\n"
344
- f"2. Load the variables: [bold cyan]export $(cat {env_filepath.name} | grep -v '^#' | xargs)[/bold cyan]\n"
345
- f"3. Or source it: [bold cyan]source {env_filepath.name}[/bold cyan]\n"
346
- f"4. The Gemini CLI provider will automatically use these environment variables"
 
 
 
347
  )
348
  console.print(Panel(success_text, style="bold green", title="Success"))
349
  else:
@@ -403,22 +473,30 @@ async def export_qwen_code_to_env():
403
  # Extract metadata
404
  email = creds.get("_proxy_metadata", {}).get("email", "unknown")
405
 
406
- # Generate .env file name
 
 
 
407
  safe_email = email.replace("@", "_at_").replace(".", "_")
408
- env_filename = f"qwen_code_{safe_email}.env"
409
  env_filepath = OAUTH_BASE_DIR / env_filename
410
 
411
- # Build .env content
 
 
 
412
  env_lines = [
413
- f"# Qwen Code Credential for: {email}",
414
- f"# Generated from: {cred_file.name}",
415
  f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
 
 
 
416
  "",
417
- f"QWEN_CODE_ACCESS_TOKEN={creds.get('access_token', '')}",
418
- f"QWEN_CODE_REFRESH_TOKEN={creds.get('refresh_token', '')}",
419
- f"QWEN_CODE_EXPIRY_DATE={creds.get('expiry_date', 0)}",
420
- f"QWEN_CODE_RESOURCE_URL={creds.get('resource_url', 'https://portal.qwen.ai/v1')}",
421
- f"QWEN_CODE_EMAIL={email}",
422
  ]
423
 
424
  # Write to .env file
@@ -427,11 +505,13 @@ async def export_qwen_code_to_env():
427
 
428
  success_text = Text.from_markup(
429
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
430
- f"To use this credential:\n"
431
- f"1. Copy [bold yellow]{env_filepath.name}[/bold yellow] to your deployment environment\n"
432
- f"2. Load the variables: [bold cyan]export $(cat {env_filepath.name} | grep -v '^#' | xargs)[/bold cyan]\n"
433
- f"3. Or source it: [bold cyan]source {env_filepath.name}[/bold cyan]\n"
434
- f"4. The Qwen Code provider will automatically use these environment variables"
 
 
435
  )
436
  console.print(Panel(success_text, style="bold green", title="Success"))
437
  else:
@@ -445,12 +525,12 @@ async def export_qwen_code_to_env():
445
  async def export_iflow_to_env():
446
  """
447
  Export an iFlow credential JSON file to .env format.
448
- Generates one .env file per credential.
449
  """
450
  console.print(Panel("[bold cyan]Export iFlow Credential to .env[/bold cyan]", expand=False))
451
 
452
  # Find all iflow credentials
453
- iflow_files = list(OAUTH_BASE_DIR.glob("iflow_oauth_*.json"))
454
 
455
  if not iflow_files:
456
  console.print(Panel("No iFlow credentials found. Please add one first using 'Add OAuth Credential'.",
@@ -491,25 +571,32 @@ async def export_iflow_to_env():
491
  # Extract metadata
492
  email = creds.get("_proxy_metadata", {}).get("email", "unknown")
493
 
494
- # Generate .env file name
 
 
 
495
  safe_email = email.replace("@", "_at_").replace(".", "_")
496
- env_filename = f"iflow_{safe_email}.env"
497
  env_filepath = OAUTH_BASE_DIR / env_filename
498
 
499
- # Build .env content
500
- # IMPORTANT: iFlow requires BOTH OAuth tokens AND the API key for API requests
 
 
501
  env_lines = [
502
- f"# iFlow Credential for: {email}",
503
- f"# Generated from: {cred_file.name}",
504
  f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
 
 
 
505
  "",
506
- f"IFLOW_ACCESS_TOKEN={creds.get('access_token', '')}",
507
- f"IFLOW_REFRESH_TOKEN={creds.get('refresh_token', '')}",
508
- f"IFLOW_API_KEY={creds.get('api_key', '')}",
509
- f"IFLOW_EXPIRY_DATE={creds.get('expiry_date', '')}",
510
- f"IFLOW_EMAIL={email}",
511
- f"IFLOW_TOKEN_TYPE={creds.get('token_type', 'Bearer')}",
512
- f"IFLOW_SCOPE={creds.get('scope', 'read write')}",
513
  ]
514
 
515
  # Write to .env file
@@ -518,11 +605,13 @@ async def export_iflow_to_env():
518
 
519
  success_text = Text.from_markup(
520
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
521
- f"To use this credential:\n"
522
- f"1. Copy [bold yellow]{env_filepath.name}[/bold yellow] to your deployment environment\n"
523
- f"2. Load the variables: [bold cyan]export $(cat {env_filepath.name} | grep -v '^#' | xargs)[/bold cyan]\n"
524
- f"3. Or source it: [bold cyan]source {env_filepath.name}[/bold cyan]\n"
525
- f"4. The iFlow provider will automatically use these environment variables"
 
 
526
  )
527
  console.print(Panel(success_text, style="bold green", title="Success"))
528
  else:
@@ -536,12 +625,12 @@ async def export_iflow_to_env():
536
  async def export_antigravity_to_env():
537
  """
538
  Export an Antigravity credential JSON file to .env format.
539
- Generates one .env file per credential.
540
  """
541
  console.print(Panel("[bold cyan]Export Antigravity Credential to .env[/bold cyan]", expand=False))
542
 
543
  # Find all antigravity credentials
544
- antigravity_files = list(OAUTH_BASE_DIR.glob("antigravity_oauth_*.json"))
545
 
546
  if not antigravity_files:
547
  console.print(Panel("No Antigravity credentials found. Please add one first using 'Add OAuth Credential'.",
@@ -582,26 +671,23 @@ async def export_antigravity_to_env():
582
  # Extract metadata
583
  email = creds.get("_proxy_metadata", {}).get("email", "unknown")
584
 
585
- # Generate .env file name
 
 
 
586
  safe_email = email.replace("@", "_at_").replace(".", "_")
587
- env_filename = f"antigravity_{safe_email}.env"
588
  env_filepath = OAUTH_BASE_DIR / env_filename
589
 
590
- # Build .env content
591
- env_lines = [
592
- f"# Antigravity Credential for: {email}",
593
- f"# Generated from: {cred_file.name}",
594
- f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
595
- "",
596
- f"ANTIGRAVITY_ACCESS_TOKEN={creds.get('access_token', '')}",
597
- f"ANTIGRAVITY_REFRESH_TOKEN={creds.get('refresh_token', '')}",
598
- f"ANTIGRAVITY_EXPIRY_DATE={creds.get('expiry_date', 0)}",
599
- f"ANTIGRAVITY_CLIENT_ID={creds.get('client_id', '')}",
600
- f"ANTIGRAVITY_CLIENT_SECRET={creds.get('client_secret', '')}",
601
- f"ANTIGRAVITY_TOKEN_URI={creds.get('token_uri', 'https://oauth2.googleapis.com/token')}",
602
- f"ANTIGRAVITY_UNIVERSE_DOMAIN={creds.get('universe_domain', 'googleapis.com')}",
603
- f"ANTIGRAVITY_EMAIL={email}",
604
- ]
605
 
606
  # Write to .env file
607
  with open(env_filepath, 'w') as f:
@@ -609,11 +695,14 @@ async def export_antigravity_to_env():
609
 
610
  success_text = Text.from_markup(
611
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
612
- f"To use this credential:\n"
613
- f"1. Copy [bold yellow]{env_filepath.name}[/bold yellow] to your deployment environment\n"
614
- f"2. Load the variables: [bold cyan]export $(cat {env_filepath.name} | grep -v '^#' | xargs)[/bold cyan]\n"
615
- f"3. Or source it: [bold cyan]source {env_filepath.name}[/bold cyan]\n"
616
- f"4. The Antigravity provider will automatically use these environment variables"
 
 
 
617
  )
618
  console.print(Panel(success_text, style="bold green", title="Success"))
619
  else:
 
36
  _provider_plugins = pp
37
  return _provider_factory, _provider_plugins
38
 
39
+
40
+ def _get_credential_number_from_filename(filename: str) -> int:
41
+ """
42
+ Extract credential number from filename like 'provider_oauth_1.json' -> 1
43
+ """
44
+ match = re.search(r'_oauth_(\d+)\.json$', filename)
45
+ if match:
46
+ return int(match.group(1))
47
+ return 1
48
+
49
+
50
+ def _build_env_export_content(
51
+ provider_prefix: str,
52
+ cred_number: int,
53
+ creds: dict,
54
+ email: str,
55
+ extra_fields: dict = None,
56
+ include_client_creds: bool = True
57
+ ) -> tuple[list[str], str]:
58
+ """
59
+ Build .env content for OAuth credential export with numbered format.
60
+ Exports all fields from the JSON file as a 1-to-1 mirror.
61
+
62
+ Args:
63
+ provider_prefix: Environment variable prefix (e.g., "ANTIGRAVITY", "GEMINI_CLI")
64
+ cred_number: Credential number for this export (1, 2, 3, etc.)
65
+ creds: The credential dictionary loaded from JSON
66
+ email: User email for comments
67
+ extra_fields: Optional dict of additional fields to include
68
+ include_client_creds: Whether to include client_id/secret (Google OAuth providers)
69
+
70
+ Returns:
71
+ Tuple of (env_lines list, numbered_prefix string for display)
72
+ """
73
+ # Use numbered format: PROVIDER_N_ACCESS_TOKEN
74
+ numbered_prefix = f"{provider_prefix}_{cred_number}"
75
+
76
+ env_lines = [
77
+ f"# {provider_prefix} Credential #{cred_number} for: {email}",
78
+ f"# Exported from: {provider_prefix.lower()}_oauth_{cred_number}.json",
79
+ f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
80
+ f"# ",
81
+ f"# To combine multiple credentials into one .env file, copy these lines",
82
+ f"# and ensure each credential has a unique number (1, 2, 3, etc.)",
83
+ "",
84
+ f"{numbered_prefix}_ACCESS_TOKEN={creds.get('access_token', '')}",
85
+ f"{numbered_prefix}_REFRESH_TOKEN={creds.get('refresh_token', '')}",
86
+ f"{numbered_prefix}_SCOPE={creds.get('scope', '')}",
87
+ f"{numbered_prefix}_TOKEN_TYPE={creds.get('token_type', 'Bearer')}",
88
+ f"{numbered_prefix}_ID_TOKEN={creds.get('id_token', '')}",
89
+ f"{numbered_prefix}_EXPIRY_DATE={creds.get('expiry_date', 0)}",
90
+ ]
91
+
92
+ if include_client_creds:
93
+ env_lines.extend([
94
+ f"{numbered_prefix}_CLIENT_ID={creds.get('client_id', '')}",
95
+ f"{numbered_prefix}_CLIENT_SECRET={creds.get('client_secret', '')}",
96
+ f"{numbered_prefix}_TOKEN_URI={creds.get('token_uri', 'https://oauth2.googleapis.com/token')}",
97
+ f"{numbered_prefix}_UNIVERSE_DOMAIN={creds.get('universe_domain', 'googleapis.com')}",
98
+ ])
99
+
100
+ env_lines.append(f"{numbered_prefix}_EMAIL={email}")
101
+
102
+ # Add extra provider-specific fields
103
+ if extra_fields:
104
+ for key, value in extra_fields.items():
105
+ if value: # Only add non-empty values
106
+ env_lines.append(f"{numbered_prefix}_{key}={value}")
107
+
108
+ return env_lines, numbered_prefix
109
+
110
  def ensure_env_defaults():
111
  """
112
  Ensures the .env file exists and contains essential default values like PROXY_API_KEY.
 
327
  async def export_gemini_cli_to_env():
328
  """
329
  Export a Gemini CLI credential JSON file to .env format.
330
+ Uses numbered format (GEMINI_CLI_1_*, GEMINI_CLI_2_*) for multiple credential support.
331
  """
332
  console.print(Panel("[bold cyan]Export Gemini CLI Credential to .env[/bold cyan]", expand=False))
333
 
334
  # Find all gemini_cli credentials
335
+ gemini_cli_files = sorted(list(OAUTH_BASE_DIR.glob("gemini_cli_oauth_*.json")))
336
 
337
  if not gemini_cli_files:
338
  console.print(Panel("No Gemini CLI credentials found. Please add one first using 'Add OAuth Credential'.",
 
375
  project_id = creds.get("_proxy_metadata", {}).get("project_id", "")
376
  tier = creds.get("_proxy_metadata", {}).get("tier", "")
377
 
378
+ # Get credential number from filename
379
+ cred_number = _get_credential_number_from_filename(cred_file.name)
380
+
381
+ # Generate .env file name with credential number
382
  safe_email = email.replace("@", "_at_").replace(".", "_")
383
+ env_filename = f"gemini_cli_{cred_number}_{safe_email}.env"
384
  env_filepath = OAUTH_BASE_DIR / env_filename
385
 
386
+ # Build extra fields
387
+ extra_fields = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  if project_id:
389
+ extra_fields["PROJECT_ID"] = project_id
 
 
390
  if tier:
391
+ extra_fields["TIER"] = tier
392
+
393
+ # Build .env content using helper
394
+ env_lines, numbered_prefix = _build_env_export_content(
395
+ provider_prefix="GEMINI_CLI",
396
+ cred_number=cred_number,
397
+ creds=creds,
398
+ email=email,
399
+ extra_fields=extra_fields,
400
+ include_client_creds=True
401
+ )
402
 
403
  # Write to .env file
404
  with open(env_filepath, 'w') as f:
 
406
 
407
  success_text = Text.from_markup(
408
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
409
+ f"[bold]Environment variable prefix:[/bold] [cyan]{numbered_prefix}_*[/cyan]\n\n"
410
+ f"[bold]To use this credential:[/bold]\n"
411
+ f"1. Copy the contents to your main .env file, OR\n"
412
+ f"2. Source it: [bold cyan]source {env_filepath.name}[/bold cyan] (Linux/Mac)\n"
413
+ f"3. Or on Windows: [bold cyan]Get-Content {env_filepath.name} | ForEach-Object {{ $_ -replace '^([^#].*)$', 'set $1' }} | cmd[/bold cyan]\n\n"
414
+ f"[bold]To combine multiple credentials:[/bold]\n"
415
+ f"Copy lines from multiple .env files into one file.\n"
416
+ f"Each credential uses a unique number ({numbered_prefix}_*)."
417
  )
418
  console.print(Panel(success_text, style="bold green", title="Success"))
419
  else:
 
473
  # Extract metadata
474
  email = creds.get("_proxy_metadata", {}).get("email", "unknown")
475
 
476
+ # Get credential number from filename
477
+ cred_number = _get_credential_number_from_filename(cred_file.name)
478
+
479
+ # Generate .env file name with credential number
480
  safe_email = email.replace("@", "_at_").replace(".", "_")
481
+ env_filename = f"qwen_code_{cred_number}_{safe_email}.env"
482
  env_filepath = OAUTH_BASE_DIR / env_filename
483
 
484
+ # Use numbered format: QWEN_CODE_N_*
485
+ numbered_prefix = f"QWEN_CODE_{cred_number}"
486
+
487
+ # Build .env content (Qwen has different structure)
488
  env_lines = [
489
+ f"# QWEN_CODE Credential #{cred_number} for: {email}",
 
490
  f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
491
+ f"# ",
492
+ f"# To combine multiple credentials into one .env file, copy these lines",
493
+ f"# and ensure each credential has a unique number (1, 2, 3, etc.)",
494
  "",
495
+ f"{numbered_prefix}_ACCESS_TOKEN={creds.get('access_token', '')}",
496
+ f"{numbered_prefix}_REFRESH_TOKEN={creds.get('refresh_token', '')}",
497
+ f"{numbered_prefix}_EXPIRY_DATE={creds.get('expiry_date', 0)}",
498
+ f"{numbered_prefix}_RESOURCE_URL={creds.get('resource_url', 'https://portal.qwen.ai/v1')}",
499
+ f"{numbered_prefix}_EMAIL={email}",
500
  ]
501
 
502
  # Write to .env file
 
505
 
506
  success_text = Text.from_markup(
507
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
508
+ f"[bold]Environment variable prefix:[/bold] [cyan]{numbered_prefix}_*[/cyan]\n\n"
509
+ f"[bold]To use this credential:[/bold]\n"
510
+ f"1. Copy the contents to your main .env file, OR\n"
511
+ f"2. Source it: [bold cyan]source {env_filepath.name}[/bold cyan] (Linux/Mac)\n\n"
512
+ f"[bold]To combine multiple credentials:[/bold]\n"
513
+ f"Copy lines from multiple .env files into one file.\n"
514
+ f"Each credential uses a unique number ({numbered_prefix}_*)."
515
  )
516
  console.print(Panel(success_text, style="bold green", title="Success"))
517
  else:
 
525
  async def export_iflow_to_env():
526
  """
527
  Export an iFlow credential JSON file to .env format.
528
+ Uses numbered format (IFLOW_1_*, IFLOW_2_*) for multiple credential support.
529
  """
530
  console.print(Panel("[bold cyan]Export iFlow Credential to .env[/bold cyan]", expand=False))
531
 
532
  # Find all iflow credentials
533
+ iflow_files = sorted(list(OAUTH_BASE_DIR.glob("iflow_oauth_*.json")))
534
 
535
  if not iflow_files:
536
  console.print(Panel("No iFlow credentials found. Please add one first using 'Add OAuth Credential'.",
 
571
  # Extract metadata
572
  email = creds.get("_proxy_metadata", {}).get("email", "unknown")
573
 
574
+ # Get credential number from filename
575
+ cred_number = _get_credential_number_from_filename(cred_file.name)
576
+
577
+ # Generate .env file name with credential number
578
  safe_email = email.replace("@", "_at_").replace(".", "_")
579
+ env_filename = f"iflow_{cred_number}_{safe_email}.env"
580
  env_filepath = OAUTH_BASE_DIR / env_filename
581
 
582
+ # Use numbered format: IFLOW_N_*
583
+ numbered_prefix = f"IFLOW_{cred_number}"
584
+
585
+ # Build .env content (iFlow has different structure with API key)
586
  env_lines = [
587
+ f"# IFLOW Credential #{cred_number} for: {email}",
 
588
  f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
589
+ f"# ",
590
+ f"# To combine multiple credentials into one .env file, copy these lines",
591
+ f"# and ensure each credential has a unique number (1, 2, 3, etc.)",
592
  "",
593
+ f"{numbered_prefix}_ACCESS_TOKEN={creds.get('access_token', '')}",
594
+ f"{numbered_prefix}_REFRESH_TOKEN={creds.get('refresh_token', '')}",
595
+ f"{numbered_prefix}_API_KEY={creds.get('api_key', '')}",
596
+ f"{numbered_prefix}_EXPIRY_DATE={creds.get('expiry_date', '')}",
597
+ f"{numbered_prefix}_EMAIL={email}",
598
+ f"{numbered_prefix}_TOKEN_TYPE={creds.get('token_type', 'Bearer')}",
599
+ f"{numbered_prefix}_SCOPE={creds.get('scope', 'read write')}",
600
  ]
601
 
602
  # Write to .env file
 
605
 
606
  success_text = Text.from_markup(
607
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
608
+ f"[bold]Environment variable prefix:[/bold] [cyan]{numbered_prefix}_*[/cyan]\n\n"
609
+ f"[bold]To use this credential:[/bold]\n"
610
+ f"1. Copy the contents to your main .env file, OR\n"
611
+ f"2. Source it: [bold cyan]source {env_filepath.name}[/bold cyan] (Linux/Mac)\n\n"
612
+ f"[bold]To combine multiple credentials:[/bold]\n"
613
+ f"Copy lines from multiple .env files into one file.\n"
614
+ f"Each credential uses a unique number ({numbered_prefix}_*)."
615
  )
616
  console.print(Panel(success_text, style="bold green", title="Success"))
617
  else:
 
625
  async def export_antigravity_to_env():
626
  """
627
  Export an Antigravity credential JSON file to .env format.
628
+ Uses numbered format (ANTIGRAVITY_1_*, ANTIGRAVITY_2_*) for multiple credential support.
629
  """
630
  console.print(Panel("[bold cyan]Export Antigravity Credential to .env[/bold cyan]", expand=False))
631
 
632
  # Find all antigravity credentials
633
+ antigravity_files = sorted(list(OAUTH_BASE_DIR.glob("antigravity_oauth_*.json")))
634
 
635
  if not antigravity_files:
636
  console.print(Panel("No Antigravity credentials found. Please add one first using 'Add OAuth Credential'.",
 
671
  # Extract metadata
672
  email = creds.get("_proxy_metadata", {}).get("email", "unknown")
673
 
674
+ # Get credential number from filename
675
+ cred_number = _get_credential_number_from_filename(cred_file.name)
676
+
677
+ # Generate .env file name with credential number
678
  safe_email = email.replace("@", "_at_").replace(".", "_")
679
+ env_filename = f"antigravity_{cred_number}_{safe_email}.env"
680
  env_filepath = OAUTH_BASE_DIR / env_filename
681
 
682
+ # Build .env content using helper
683
+ env_lines, numbered_prefix = _build_env_export_content(
684
+ provider_prefix="ANTIGRAVITY",
685
+ cred_number=cred_number,
686
+ creds=creds,
687
+ email=email,
688
+ extra_fields=None,
689
+ include_client_creds=True
690
+ )
 
 
 
 
 
 
691
 
692
  # Write to .env file
693
  with open(env_filepath, 'w') as f:
 
695
 
696
  success_text = Text.from_markup(
697
  f"Successfully exported credential to [bold yellow]'{env_filepath}'[/bold yellow]\n\n"
698
+ f"[bold]Environment variable prefix:[/bold] [cyan]{numbered_prefix}_*[/cyan]\n\n"
699
+ f"[bold]To use this credential:[/bold]\n"
700
+ f"1. Copy the contents to your main .env file, OR\n"
701
+ f"2. Source it: [bold cyan]source {env_filepath.name}[/bold cyan] (Linux/Mac)\n"
702
+ f"3. Or on Windows: [bold cyan]Get-Content {env_filepath.name} | ForEach-Object {{ $_ -replace '^([^#].*)$', 'set $1' }} | cmd[/bold cyan]\n\n"
703
+ f"[bold]To combine multiple credentials:[/bold]\n"
704
+ f"Copy lines from multiple .env files into one file.\n"
705
+ f"Each credential uses a unique number ({numbered_prefix}_*)."
706
  )
707
  console.print(Panel(success_text, style="bold green", title="Success"))
708
  else:
src/rotator_library/providers/google_oauth_base.py CHANGED
@@ -77,64 +77,103 @@ class GoogleOAuthBase:
77
  self._queue_tracking_lock = asyncio.Lock() # Protects queue sets
78
  self._queue_processor_task: Optional[asyncio.Task] = None # Background worker task
79
 
80
- def _load_from_env(self) -> Optional[Dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  """
82
  Load OAuth credentials from environment variables for stateless deployments.
83
 
84
- Expected environment variables:
85
- - {ENV_PREFIX}_ACCESS_TOKEN (required)
86
- - {ENV_PREFIX}_REFRESH_TOKEN (required)
87
- - {ENV_PREFIX}_EXPIRY_DATE (optional, defaults to 0)
88
- - {ENV_PREFIX}_CLIENT_ID (optional, uses default)
89
- - {ENV_PREFIX}_CLIENT_SECRET (optional, uses default)
90
- - {ENV_PREFIX}_TOKEN_URI (optional, uses default)
91
- - {ENV_PREFIX}_UNIVERSE_DOMAIN (optional, defaults to googleapis.com)
92
- - {ENV_PREFIX}_EMAIL (optional, defaults to "env-user")
93
- - {ENV_PREFIX}_PROJECT_ID (optional)
94
- - {ENV_PREFIX}_TIER (optional)
 
 
 
 
 
 
95
 
96
  Returns:
97
  Dict with credential structure if env vars present, None otherwise
98
  """
99
- access_token = os.getenv(f"{self.ENV_PREFIX}_ACCESS_TOKEN")
100
- refresh_token = os.getenv(f"{self.ENV_PREFIX}_REFRESH_TOKEN")
 
 
 
 
 
 
 
 
 
 
101
 
102
  # Both access and refresh tokens are required
103
  if not (access_token and refresh_token):
104
  return None
105
 
106
- lib_logger.debug(f"Loading {self.ENV_PREFIX} credentials from environment variables")
107
 
108
  # Parse expiry_date as float, default to 0 if not present
109
- expiry_str = os.getenv(f"{self.ENV_PREFIX}_EXPIRY_DATE", "0")
110
  try:
111
  expiry_date = float(expiry_str)
112
  except ValueError:
113
- lib_logger.warning(f"Invalid {self.ENV_PREFIX}_EXPIRY_DATE value: {expiry_str}, using 0")
114
  expiry_date = 0
115
 
116
  creds = {
117
  "access_token": access_token,
118
  "refresh_token": refresh_token,
119
  "expiry_date": expiry_date,
120
- "client_id": os.getenv(f"{self.ENV_PREFIX}_CLIENT_ID", self.CLIENT_ID),
121
- "client_secret": os.getenv(f"{self.ENV_PREFIX}_CLIENT_SECRET", self.CLIENT_SECRET),
122
- "token_uri": os.getenv(f"{self.ENV_PREFIX}_TOKEN_URI", self.TOKEN_URI),
123
- "universe_domain": os.getenv(f"{self.ENV_PREFIX}_UNIVERSE_DOMAIN", "googleapis.com"),
124
  "_proxy_metadata": {
125
- "email": os.getenv(f"{self.ENV_PREFIX}_EMAIL", "env-user"),
126
  "last_check_timestamp": time.time(),
127
- "loaded_from_env": True # Flag to indicate env-based credentials
 
128
  }
129
  }
130
 
131
  # Add project_id if provided
132
- project_id = os.getenv(f"{self.ENV_PREFIX}_PROJECT_ID")
133
  if project_id:
134
  creds["_proxy_metadata"]["project_id"] = project_id
135
 
136
  # Add tier if provided
137
- tier = os.getenv(f"{self.ENV_PREFIX}_TIER")
138
  if tier:
139
  creds["_proxy_metadata"]["tier"] = tier
140
 
@@ -148,7 +187,19 @@ class GoogleOAuthBase:
148
  if path in self._credentials_cache:
149
  return self._credentials_cache[path]
150
 
151
- # First, try loading from environment variables
 
 
 
 
 
 
 
 
 
 
 
 
152
  env_creds = self._load_from_env()
153
  if env_creds:
154
  lib_logger.info(f"Using {self.ENV_PREFIX} credentials from environment variables")
@@ -170,6 +221,8 @@ class GoogleOAuthBase:
170
  raise IOError(f"{self.ENV_PREFIX} OAuth credential file not found at '{path}'")
171
  except Exception as e:
172
  raise IOError(f"Failed to load {self.ENV_PREFIX} OAuth credentials from '{path}': {e}")
 
 
173
 
174
  async def _save_credentials(self, path: str, creds: Dict[str, Any]):
175
  # Don't save to file if credentials were loaded from environment
 
77
  self._queue_tracking_lock = asyncio.Lock() # Protects queue sets
78
  self._queue_processor_task: Optional[asyncio.Task] = None # Background worker task
79
 
80
+ def _parse_env_credential_path(self, path: str) -> Optional[str]:
81
+ """
82
+ Parse a virtual env:// path and return the credential index.
83
+
84
+ Supported formats:
85
+ - "env://provider/0" - Legacy single credential (no index in env var names)
86
+ - "env://provider/1" - First numbered credential (PROVIDER_1_ACCESS_TOKEN)
87
+ - "env://provider/2" - Second numbered credential, etc.
88
+
89
+ Returns:
90
+ The credential index as string ("0" for legacy, "1", "2", etc. for numbered)
91
+ or None if path is not an env:// path
92
+ """
93
+ if not path.startswith("env://"):
94
+ return None
95
+
96
+ # Parse: env://provider/index
97
+ parts = path[6:].split("/") # Remove "env://" prefix
98
+ if len(parts) >= 2:
99
+ return parts[1] # Return the index
100
+ return "0" # Default to legacy format
101
+
102
+ def _load_from_env(self, credential_index: Optional[str] = None) -> Optional[Dict[str, Any]]:
103
  """
104
  Load OAuth credentials from environment variables for stateless deployments.
105
 
106
+ Supports two formats:
107
+ 1. Legacy (credential_index="0" or None): PROVIDER_ACCESS_TOKEN
108
+ 2. Numbered (credential_index="1", "2", etc.): PROVIDER_1_ACCESS_TOKEN, PROVIDER_2_ACCESS_TOKEN
109
+
110
+ Expected environment variables (for numbered format with index N):
111
+ - {ENV_PREFIX}_{N}_ACCESS_TOKEN (required)
112
+ - {ENV_PREFIX}_{N}_REFRESH_TOKEN (required)
113
+ - {ENV_PREFIX}_{N}_EXPIRY_DATE (optional, defaults to 0)
114
+ - {ENV_PREFIX}_{N}_CLIENT_ID (optional, uses default)
115
+ - {ENV_PREFIX}_{N}_CLIENT_SECRET (optional, uses default)
116
+ - {ENV_PREFIX}_{N}_TOKEN_URI (optional, uses default)
117
+ - {ENV_PREFIX}_{N}_UNIVERSE_DOMAIN (optional, defaults to googleapis.com)
118
+ - {ENV_PREFIX}_{N}_EMAIL (optional, defaults to "env-user-{N}")
119
+ - {ENV_PREFIX}_{N}_PROJECT_ID (optional)
120
+ - {ENV_PREFIX}_{N}_TIER (optional)
121
+
122
+ For legacy format (index="0" or None), omit the _{N}_ part.
123
 
124
  Returns:
125
  Dict with credential structure if env vars present, None otherwise
126
  """
127
+ # Determine the env var prefix based on credential index
128
+ if credential_index and credential_index != "0":
129
+ # Numbered format: PROVIDER_N_ACCESS_TOKEN
130
+ prefix = f"{self.ENV_PREFIX}_{credential_index}"
131
+ default_email = f"env-user-{credential_index}"
132
+ else:
133
+ # Legacy format: PROVIDER_ACCESS_TOKEN
134
+ prefix = self.ENV_PREFIX
135
+ default_email = "env-user"
136
+
137
+ access_token = os.getenv(f"{prefix}_ACCESS_TOKEN")
138
+ refresh_token = os.getenv(f"{prefix}_REFRESH_TOKEN")
139
 
140
  # Both access and refresh tokens are required
141
  if not (access_token and refresh_token):
142
  return None
143
 
144
+ lib_logger.debug(f"Loading {prefix} credentials from environment variables")
145
 
146
  # Parse expiry_date as float, default to 0 if not present
147
+ expiry_str = os.getenv(f"{prefix}_EXPIRY_DATE", "0")
148
  try:
149
  expiry_date = float(expiry_str)
150
  except ValueError:
151
+ lib_logger.warning(f"Invalid {prefix}_EXPIRY_DATE value: {expiry_str}, using 0")
152
  expiry_date = 0
153
 
154
  creds = {
155
  "access_token": access_token,
156
  "refresh_token": refresh_token,
157
  "expiry_date": expiry_date,
158
+ "client_id": os.getenv(f"{prefix}_CLIENT_ID", self.CLIENT_ID),
159
+ "client_secret": os.getenv(f"{prefix}_CLIENT_SECRET", self.CLIENT_SECRET),
160
+ "token_uri": os.getenv(f"{prefix}_TOKEN_URI", self.TOKEN_URI),
161
+ "universe_domain": os.getenv(f"{prefix}_UNIVERSE_DOMAIN", "googleapis.com"),
162
  "_proxy_metadata": {
163
+ "email": os.getenv(f"{prefix}_EMAIL", default_email),
164
  "last_check_timestamp": time.time(),
165
+ "loaded_from_env": True, # Flag to indicate env-based credentials
166
+ "env_credential_index": credential_index or "0" # Track which env credential this is
167
  }
168
  }
169
 
170
  # Add project_id if provided
171
+ project_id = os.getenv(f"{prefix}_PROJECT_ID")
172
  if project_id:
173
  creds["_proxy_metadata"]["project_id"] = project_id
174
 
175
  # Add tier if provided
176
+ tier = os.getenv(f"{prefix}_TIER")
177
  if tier:
178
  creds["_proxy_metadata"]["tier"] = tier
179
 
 
187
  if path in self._credentials_cache:
188
  return self._credentials_cache[path]
189
 
190
+ # Check if this is a virtual env:// path
191
+ credential_index = self._parse_env_credential_path(path)
192
+ if credential_index is not None:
193
+ # Load from environment variables with specific index
194
+ env_creds = self._load_from_env(credential_index)
195
+ if env_creds:
196
+ lib_logger.info(f"Using {self.ENV_PREFIX} credentials from environment variables (index: {credential_index})")
197
+ self._credentials_cache[path] = env_creds
198
+ return env_creds
199
+ else:
200
+ raise IOError(f"Environment variables for {self.ENV_PREFIX} credential index {credential_index} not found")
201
+
202
+ # For file paths, first try loading from legacy env vars (for backwards compatibility)
203
  env_creds = self._load_from_env()
204
  if env_creds:
205
  lib_logger.info(f"Using {self.ENV_PREFIX} credentials from environment variables")
 
221
  raise IOError(f"{self.ENV_PREFIX} OAuth credential file not found at '{path}'")
222
  except Exception as e:
223
  raise IOError(f"Failed to load {self.ENV_PREFIX} OAuth credentials from '{path}': {e}")
224
+ except Exception as e:
225
+ raise IOError(f"Failed to load {self.ENV_PREFIX} OAuth credentials from '{path}': {e}")
226
 
227
  async def _save_credentials(self, path: str, creds: Dict[str, Any]):
228
  # Don't save to file if credentials were loaded from environment
src/rotator_library/providers/iflow_auth_base.py CHANGED
@@ -158,47 +158,79 @@ class IFlowAuthBase:
158
  self._queue_tracking_lock = asyncio.Lock() # Protects queue sets
159
  self._queue_processor_task: Optional[asyncio.Task] = None # Background worker task
160
 
161
- def _load_from_env(self) -> Optional[Dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  """
163
  Load OAuth credentials from environment variables for stateless deployments.
164
 
165
- Expected environment variables:
166
- - IFLOW_ACCESS_TOKEN (required)
167
- - IFLOW_REFRESH_TOKEN (required)
168
- - IFLOW_API_KEY (required - critical for iFlow!)
169
- - IFLOW_EXPIRY_DATE (optional, defaults to empty string)
170
- - IFLOW_EMAIL (optional, defaults to "env-user")
171
- - IFLOW_TOKEN_TYPE (optional, defaults to "Bearer")
172
- - IFLOW_SCOPE (optional, defaults to "read write")
 
 
 
 
173
 
174
  Returns:
175
  Dict with credential structure if env vars present, None otherwise
176
  """
177
- access_token = os.getenv("IFLOW_ACCESS_TOKEN")
178
- refresh_token = os.getenv("IFLOW_REFRESH_TOKEN")
179
- api_key = os.getenv("IFLOW_API_KEY")
 
 
 
 
 
 
 
 
180
 
181
  # All three are required for iFlow
182
  if not (access_token and refresh_token and api_key):
183
  return None
184
 
185
- lib_logger.debug("Loading iFlow credentials from environment variables")
186
 
187
  # Parse expiry_date as string (ISO 8601 format)
188
- expiry_str = os.getenv("IFLOW_EXPIRY_DATE", "")
189
 
190
  creds = {
191
  "access_token": access_token,
192
  "refresh_token": refresh_token,
193
  "api_key": api_key, # Critical for iFlow!
194
  "expiry_date": expiry_str,
195
- "email": os.getenv("IFLOW_EMAIL", "env-user"),
196
- "token_type": os.getenv("IFLOW_TOKEN_TYPE", "Bearer"),
197
- "scope": os.getenv("IFLOW_SCOPE", "read write"),
198
  "_proxy_metadata": {
199
- "email": os.getenv("IFLOW_EMAIL", "env-user"),
200
  "last_check_timestamp": time.time(),
201
- "loaded_from_env": True # Flag to indicate env-based credentials
 
202
  }
203
  }
204
 
@@ -227,11 +259,21 @@ class IFlowAuthBase:
227
  if path in self._credentials_cache:
228
  return self._credentials_cache[path]
229
 
230
- # First, try loading from environment variables
 
 
 
 
 
 
 
 
 
 
 
231
  env_creds = self._load_from_env()
232
  if env_creds:
233
  lib_logger.info("Using iFlow credentials from environment variables")
234
- # Cache env-based credentials using the path as key
235
  self._credentials_cache[path] = env_creds
236
  return env_creds
237
 
 
158
  self._queue_tracking_lock = asyncio.Lock() # Protects queue sets
159
  self._queue_processor_task: Optional[asyncio.Task] = None # Background worker task
160
 
161
+ def _parse_env_credential_path(self, path: str) -> Optional[str]:
162
+ """
163
+ Parse a virtual env:// path and return the credential index.
164
+
165
+ Supported formats:
166
+ - "env://provider/0" - Legacy single credential (no index in env var names)
167
+ - "env://provider/1" - First numbered credential (IFLOW_1_ACCESS_TOKEN)
168
+
169
+ Returns:
170
+ The credential index as string, or None if path is not an env:// path
171
+ """
172
+ if not path.startswith("env://"):
173
+ return None
174
+
175
+ parts = path[6:].split("/")
176
+ if len(parts) >= 2:
177
+ return parts[1]
178
+ return "0"
179
+
180
+ def _load_from_env(self, credential_index: Optional[str] = None) -> Optional[Dict[str, Any]]:
181
  """
182
  Load OAuth credentials from environment variables for stateless deployments.
183
 
184
+ Supports two formats:
185
+ 1. Legacy (credential_index="0" or None): IFLOW_ACCESS_TOKEN
186
+ 2. Numbered (credential_index="1", "2", etc.): IFLOW_1_ACCESS_TOKEN, etc.
187
+
188
+ Expected environment variables (for numbered format with index N):
189
+ - IFLOW_{N}_ACCESS_TOKEN (required)
190
+ - IFLOW_{N}_REFRESH_TOKEN (required)
191
+ - IFLOW_{N}_API_KEY (required - critical for iFlow!)
192
+ - IFLOW_{N}_EXPIRY_DATE (optional, defaults to empty string)
193
+ - IFLOW_{N}_EMAIL (optional, defaults to "env-user-{N}")
194
+ - IFLOW_{N}_TOKEN_TYPE (optional, defaults to "Bearer")
195
+ - IFLOW_{N}_SCOPE (optional, defaults to "read write")
196
 
197
  Returns:
198
  Dict with credential structure if env vars present, None otherwise
199
  """
200
+ # Determine the env var prefix based on credential index
201
+ if credential_index and credential_index != "0":
202
+ prefix = f"IFLOW_{credential_index}"
203
+ default_email = f"env-user-{credential_index}"
204
+ else:
205
+ prefix = "IFLOW"
206
+ default_email = "env-user"
207
+
208
+ access_token = os.getenv(f"{prefix}_ACCESS_TOKEN")
209
+ refresh_token = os.getenv(f"{prefix}_REFRESH_TOKEN")
210
+ api_key = os.getenv(f"{prefix}_API_KEY")
211
 
212
  # All three are required for iFlow
213
  if not (access_token and refresh_token and api_key):
214
  return None
215
 
216
+ lib_logger.debug(f"Loading iFlow credentials from environment variables (prefix: {prefix})")
217
 
218
  # Parse expiry_date as string (ISO 8601 format)
219
+ expiry_str = os.getenv(f"{prefix}_EXPIRY_DATE", "")
220
 
221
  creds = {
222
  "access_token": access_token,
223
  "refresh_token": refresh_token,
224
  "api_key": api_key, # Critical for iFlow!
225
  "expiry_date": expiry_str,
226
+ "email": os.getenv(f"{prefix}_EMAIL", default_email),
227
+ "token_type": os.getenv(f"{prefix}_TOKEN_TYPE", "Bearer"),
228
+ "scope": os.getenv(f"{prefix}_SCOPE", "read write"),
229
  "_proxy_metadata": {
230
+ "email": os.getenv(f"{prefix}_EMAIL", default_email),
231
  "last_check_timestamp": time.time(),
232
+ "loaded_from_env": True,
233
+ "env_credential_index": credential_index or "0"
234
  }
235
  }
236
 
 
259
  if path in self._credentials_cache:
260
  return self._credentials_cache[path]
261
 
262
+ # Check if this is a virtual env:// path
263
+ credential_index = self._parse_env_credential_path(path)
264
+ if credential_index is not None:
265
+ env_creds = self._load_from_env(credential_index)
266
+ if env_creds:
267
+ lib_logger.info(f"Using iFlow credentials from environment variables (index: {credential_index})")
268
+ self._credentials_cache[path] = env_creds
269
+ return env_creds
270
+ else:
271
+ raise IOError(f"Environment variables for iFlow credential index {credential_index} not found")
272
+
273
+ # For file paths, try loading from legacy env vars first
274
  env_creds = self._load_from_env()
275
  if env_creds:
276
  lib_logger.info("Using iFlow credentials from environment variables")
 
277
  self._credentials_cache[path] = env_creds
278
  return env_creds
279
 
src/rotator_library/providers/qwen_auth_base.py CHANGED
@@ -47,46 +47,78 @@ class QwenAuthBase:
47
  self._queue_tracking_lock = asyncio.Lock() # Protects queue sets
48
  self._queue_processor_task: Optional[asyncio.Task] = None # Background worker task
49
 
50
- def _load_from_env(self) -> Optional[Dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  """
52
  Load OAuth credentials from environment variables for stateless deployments.
53
 
54
- Expected environment variables:
55
- - QWEN_CODE_ACCESS_TOKEN (required)
56
- - QWEN_CODE_REFRESH_TOKEN (required)
57
- - QWEN_CODE_EXPIRY_DATE (optional, defaults to 0)
58
- - QWEN_CODE_RESOURCE_URL (optional, defaults to https://portal.qwen.ai/v1)
59
- - QWEN_CODE_EMAIL (optional, defaults to "env-user")
 
 
 
 
60
 
61
  Returns:
62
  Dict with credential structure if env vars present, None otherwise
63
  """
64
- access_token = os.getenv("QWEN_CODE_ACCESS_TOKEN")
65
- refresh_token = os.getenv("QWEN_CODE_REFRESH_TOKEN")
 
 
 
 
 
 
 
 
66
 
67
  # Both access and refresh tokens are required
68
  if not (access_token and refresh_token):
69
  return None
70
 
71
- lib_logger.debug("Loading Qwen Code credentials from environment variables")
72
 
73
  # Parse expiry_date as float, default to 0 if not present
74
- expiry_str = os.getenv("QWEN_CODE_EXPIRY_DATE", "0")
75
  try:
76
  expiry_date = float(expiry_str)
77
  except ValueError:
78
- lib_logger.warning(f"Invalid QWEN_CODE_EXPIRY_DATE value: {expiry_str}, using 0")
79
  expiry_date = 0
80
 
81
  creds = {
82
  "access_token": access_token,
83
  "refresh_token": refresh_token,
84
  "expiry_date": expiry_date,
85
- "resource_url": os.getenv("QWEN_CODE_RESOURCE_URL", "https://portal.qwen.ai/v1"),
86
  "_proxy_metadata": {
87
- "email": os.getenv("QWEN_CODE_EMAIL", "env-user"),
88
  "last_check_timestamp": time.time(),
89
- "loaded_from_env": True # Flag to indicate env-based credentials
 
90
  }
91
  }
92
 
@@ -115,11 +147,21 @@ class QwenAuthBase:
115
  if path in self._credentials_cache:
116
  return self._credentials_cache[path]
117
 
118
- # First, try loading from environment variables
 
 
 
 
 
 
 
 
 
 
 
119
  env_creds = self._load_from_env()
120
  if env_creds:
121
  lib_logger.info("Using Qwen Code credentials from environment variables")
122
- # Cache env-based credentials using the path as key
123
  self._credentials_cache[path] = env_creds
124
  return env_creds
125
 
 
47
  self._queue_tracking_lock = asyncio.Lock() # Protects queue sets
48
  self._queue_processor_task: Optional[asyncio.Task] = None # Background worker task
49
 
50
+ def _parse_env_credential_path(self, path: str) -> Optional[str]:
51
+ """
52
+ Parse a virtual env:// path and return the credential index.
53
+
54
+ Supported formats:
55
+ - "env://provider/0" - Legacy single credential (no index in env var names)
56
+ - "env://provider/1" - First numbered credential (QWEN_CODE_1_ACCESS_TOKEN)
57
+
58
+ Returns:
59
+ The credential index as string, or None if path is not an env:// path
60
+ """
61
+ if not path.startswith("env://"):
62
+ return None
63
+
64
+ parts = path[6:].split("/")
65
+ if len(parts) >= 2:
66
+ return parts[1]
67
+ return "0"
68
+
69
+ def _load_from_env(self, credential_index: Optional[str] = None) -> Optional[Dict[str, Any]]:
70
  """
71
  Load OAuth credentials from environment variables for stateless deployments.
72
 
73
+ Supports two formats:
74
+ 1. Legacy (credential_index="0" or None): QWEN_CODE_ACCESS_TOKEN
75
+ 2. Numbered (credential_index="1", "2", etc.): QWEN_CODE_1_ACCESS_TOKEN, etc.
76
+
77
+ Expected environment variables (for numbered format with index N):
78
+ - QWEN_CODE_{N}_ACCESS_TOKEN (required)
79
+ - QWEN_CODE_{N}_REFRESH_TOKEN (required)
80
+ - QWEN_CODE_{N}_EXPIRY_DATE (optional, defaults to 0)
81
+ - QWEN_CODE_{N}_RESOURCE_URL (optional, defaults to https://portal.qwen.ai/v1)
82
+ - QWEN_CODE_{N}_EMAIL (optional, defaults to "env-user-{N}")
83
 
84
  Returns:
85
  Dict with credential structure if env vars present, None otherwise
86
  """
87
+ # Determine the env var prefix based on credential index
88
+ if credential_index and credential_index != "0":
89
+ prefix = f"QWEN_CODE_{credential_index}"
90
+ default_email = f"env-user-{credential_index}"
91
+ else:
92
+ prefix = "QWEN_CODE"
93
+ default_email = "env-user"
94
+
95
+ access_token = os.getenv(f"{prefix}_ACCESS_TOKEN")
96
+ refresh_token = os.getenv(f"{prefix}_REFRESH_TOKEN")
97
 
98
  # Both access and refresh tokens are required
99
  if not (access_token and refresh_token):
100
  return None
101
 
102
+ lib_logger.debug(f"Loading Qwen Code credentials from environment variables (prefix: {prefix})")
103
 
104
  # Parse expiry_date as float, default to 0 if not present
105
+ expiry_str = os.getenv(f"{prefix}_EXPIRY_DATE", "0")
106
  try:
107
  expiry_date = float(expiry_str)
108
  except ValueError:
109
+ lib_logger.warning(f"Invalid {prefix}_EXPIRY_DATE value: {expiry_str}, using 0")
110
  expiry_date = 0
111
 
112
  creds = {
113
  "access_token": access_token,
114
  "refresh_token": refresh_token,
115
  "expiry_date": expiry_date,
116
+ "resource_url": os.getenv(f"{prefix}_RESOURCE_URL", "https://portal.qwen.ai/v1"),
117
  "_proxy_metadata": {
118
+ "email": os.getenv(f"{prefix}_EMAIL", default_email),
119
  "last_check_timestamp": time.time(),
120
+ "loaded_from_env": True,
121
+ "env_credential_index": credential_index or "0"
122
  }
123
  }
124
 
 
147
  if path in self._credentials_cache:
148
  return self._credentials_cache[path]
149
 
150
+ # Check if this is a virtual env:// path
151
+ credential_index = self._parse_env_credential_path(path)
152
+ if credential_index is not None:
153
+ env_creds = self._load_from_env(credential_index)
154
+ if env_creds:
155
+ lib_logger.info(f"Using Qwen Code credentials from environment variables (index: {credential_index})")
156
+ self._credentials_cache[path] = env_creds
157
+ return env_creds
158
+ else:
159
+ raise IOError(f"Environment variables for Qwen Code credential index {credential_index} not found")
160
+
161
+ # For file paths, try loading from legacy env vars first
162
  env_creds = self._load_from_env()
163
  if env_creds:
164
  lib_logger.info("Using Qwen Code credentials from environment variables")
 
165
  self._credentials_cache[path] = env_creds
166
  return env_creds
167
 
todo.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ ~~Refine claude injection to inject even if we have correct thinking - to force it to think if we made ultrathink prompt. If last msg is tool use and you prompt - it never thinks again.~~ Maybe done
2
+
3
+ Anthropic translation and anthropic compatible endpoint.
4
+
5
+ Refine for deployment.
6
+
7
+