Spaces:
Paused
feat(auth): ✨ add environment variable-based OAuth credential support with multi-account capability
Browse filesIntroduces 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 +293 -0
- src/rotator_library/credential_manager.py +86 -2
- src/rotator_library/credential_tool.py +178 -89
- src/rotator_library/providers/google_oauth_base.py +79 -26
- src/rotator_library/providers/iflow_auth_base.py +63 -21
- src/rotator_library/providers/qwen_auth_base.py +59 -17
- todo.md +7 -0
|
@@ -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
|
|
@@ -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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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
|
| 313 |
-
|
| 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 |
-
|
| 331 |
-
|
| 332 |
-
# Add tier if present
|
| 333 |
if tier:
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 343 |
-
f"
|
| 344 |
-
f"
|
| 345 |
-
f"
|
| 346 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 412 |
env_lines = [
|
| 413 |
-
f"#
|
| 414 |
-
f"# Generated from: {cred_file.name}",
|
| 415 |
f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
|
|
|
|
|
|
|
|
| 416 |
"",
|
| 417 |
-
f"
|
| 418 |
-
f"
|
| 419 |
-
f"
|
| 420 |
-
f"
|
| 421 |
-
f"
|
| 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"
|
| 431 |
-
f"
|
| 432 |
-
f"
|
| 433 |
-
f"
|
| 434 |
-
f"
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 500 |
-
|
|
|
|
|
|
|
| 501 |
env_lines = [
|
| 502 |
-
f"#
|
| 503 |
-
f"# Generated from: {cred_file.name}",
|
| 504 |
f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
|
|
|
|
|
|
|
|
| 505 |
"",
|
| 506 |
-
f"
|
| 507 |
-
f"
|
| 508 |
-
f"
|
| 509 |
-
f"
|
| 510 |
-
f"
|
| 511 |
-
f"
|
| 512 |
-
f"
|
| 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"
|
| 522 |
-
f"
|
| 523 |
-
f"
|
| 524 |
-
f"
|
| 525 |
-
f"
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 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"
|
| 613 |
-
f"
|
| 614 |
-
f"
|
| 615 |
-
f"
|
| 616 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
"""
|
| 82 |
Load OAuth credentials from environment variables for stateless deployments.
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
- {ENV_PREFIX}
|
| 90 |
-
- {ENV_PREFIX}
|
| 91 |
-
- {ENV_PREFIX}
|
| 92 |
-
- {ENV_PREFIX}
|
| 93 |
-
- {ENV_PREFIX}
|
| 94 |
-
- {ENV_PREFIX}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
Returns:
|
| 97 |
Dict with credential structure if env vars present, None otherwise
|
| 98 |
"""
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 107 |
|
| 108 |
# Parse expiry_date as float, default to 0 if not present
|
| 109 |
-
expiry_str = os.getenv(f"{
|
| 110 |
try:
|
| 111 |
expiry_date = float(expiry_str)
|
| 112 |
except ValueError:
|
| 113 |
-
lib_logger.warning(f"Invalid {
|
| 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"{
|
| 121 |
-
"client_secret": os.getenv(f"{
|
| 122 |
-
"token_uri": os.getenv(f"{
|
| 123 |
-
"universe_domain": os.getenv(f"{
|
| 124 |
"_proxy_metadata": {
|
| 125 |
-
"email": os.getenv(f"{
|
| 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"{
|
| 133 |
if project_id:
|
| 134 |
creds["_proxy_metadata"]["project_id"] = project_id
|
| 135 |
|
| 136 |
# Add tier if provided
|
| 137 |
-
tier = os.getenv(f"{
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
"""
|
| 163 |
Load OAuth credentials from environment variables for stateless deployments.
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
-
|
| 171 |
-
-
|
| 172 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
Returns:
|
| 175 |
Dict with credential structure if env vars present, None otherwise
|
| 176 |
"""
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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("
|
| 196 |
-
"token_type": os.getenv("
|
| 197 |
-
"scope": os.getenv("
|
| 198 |
"_proxy_metadata": {
|
| 199 |
-
"email": os.getenv("
|
| 200 |
"last_check_timestamp": time.time(),
|
| 201 |
-
"loaded_from_env": True
|
|
|
|
| 202 |
}
|
| 203 |
}
|
| 204 |
|
|
@@ -227,11 +259,21 @@ class IFlowAuthBase:
|
|
| 227 |
if path in self._credentials_cache:
|
| 228 |
return self._credentials_cache[path]
|
| 229 |
|
| 230 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"""
|
| 52 |
Load OAuth credentials from environment variables for stateless deployments.
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
Returns:
|
| 62 |
Dict with credential structure if env vars present, None otherwise
|
| 63 |
"""
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 75 |
try:
|
| 76 |
expiry_date = float(expiry_str)
|
| 77 |
except ValueError:
|
| 78 |
-
lib_logger.warning(f"Invalid
|
| 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("
|
| 86 |
"_proxy_metadata": {
|
| 87 |
-
"email": os.getenv("
|
| 88 |
"last_check_timestamp": time.time(),
|
| 89 |
-
"loaded_from_env": True
|
|
|
|
| 90 |
}
|
| 91 |
}
|
| 92 |
|
|
@@ -115,11 +147,21 @@ class QwenAuthBase:
|
|
| 115 |
if path in self._credentials_cache:
|
| 116 |
return self._credentials_cache[path]
|
| 117 |
|
| 118 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
+
|