Spaces:
Paused
Paused
Mirrowel commited on
Commit ·
622b036
1
Parent(s): f47e722
refactor(auth): enhance OAuth credential discovery and client logging
Browse filesstandardize usage of "credential" instead of "key" across the client logging to better reflect the use of complex OAuth tokens.
The credential discovery and initialization flow were made more robust:
- File paths configured in OAuth settings now correctly strip comments and quotes.
- Initialization in the main app (lifespan) now catches and logs errors for individual token setups, preventing a failure in one token from stopping the application startup.
- Added explicit FileNotFoundError handling and improved debug logging in base provider classes.
- Provider default paths were renamed for clarity (e.g., `gemini` is now `gemini_cli`).
src/proxy_app/main.py
CHANGED
|
@@ -172,18 +172,23 @@ async def lifespan(app: FastAPI):
|
|
| 172 |
discovered_creds = temp_cred_manager.discover_and_prepare()
|
| 173 |
|
| 174 |
init_tasks = []
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
| 182 |
|
| 183 |
if init_tasks:
|
|
|
|
| 184 |
# Run sequentially to allow for user input without overlap
|
| 185 |
-
for task in init_tasks:
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
logging.info("OAuth credential validation complete.")
|
| 188 |
|
| 189 |
# [NEW] Load provider-specific params
|
|
|
|
| 172 |
discovered_creds = temp_cred_manager.discover_and_prepare()
|
| 173 |
|
| 174 |
init_tasks = []
|
| 175 |
+
if discovered_creds:
|
| 176 |
+
logging.info(f"Found OAuth credentials for {len(discovered_creds)} provider(s). Validating tokens...")
|
| 177 |
+
for provider, paths in discovered_creds.items():
|
| 178 |
+
provider_plugin_class = PROVIDER_PLUGINS.get(provider)
|
| 179 |
+
if provider_plugin_class and hasattr(provider_plugin_class(), 'initialize_token'):
|
| 180 |
+
provider_instance = provider_plugin_class()
|
| 181 |
+
for path in paths:
|
| 182 |
+
init_tasks.append((provider, path, provider_instance.initialize_token(path)))
|
| 183 |
|
| 184 |
if init_tasks:
|
| 185 |
+
logging.info(f"Attempting to initialize/validate {len(init_tasks)} OAuth token(s)...")
|
| 186 |
# Run sequentially to allow for user input without overlap
|
| 187 |
+
for provider, path, task in init_tasks:
|
| 188 |
+
try:
|
| 189 |
+
await task
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logging.error(f"Failed to initialize OAuth token for {provider} at '{path}': {e}")
|
| 192 |
logging.info("OAuth credential validation complete.")
|
| 193 |
|
| 194 |
# [NEW] Load provider-specific params
|
src/rotator_library/client.py
CHANGED
|
@@ -233,7 +233,7 @@ class RotatingClient:
|
|
| 233 |
try:
|
| 234 |
while True:
|
| 235 |
if request and await request.is_disconnected():
|
| 236 |
-
lib_logger.info(f"Client disconnected. Aborting stream for
|
| 237 |
# Do not yield [DONE] because the client is gone.
|
| 238 |
# The 'finally' block will handle key release.
|
| 239 |
break
|
|
@@ -262,7 +262,7 @@ class RotatingClient:
|
|
| 262 |
# This is a critical, typed error from litellm that signals a key failure.
|
| 263 |
# We do not try to parse it here. We wrap it and raise it immediately
|
| 264 |
# for the outer retry loop to handle.
|
| 265 |
-
lib_logger.warning(f"Caught a critical API error mid-stream: {type(e).__name__}. Signaling for
|
| 266 |
raise StreamedAPIError("Provider error received in stream", data=e)
|
| 267 |
|
| 268 |
except Exception as e:
|
|
@@ -315,7 +315,7 @@ class RotatingClient:
|
|
| 315 |
|
| 316 |
except Exception as e:
|
| 317 |
# Catch any other unexpected errors during streaming.
|
| 318 |
-
lib_logger.error(f"An unexpected error occurred during the stream for
|
| 319 |
# We still need to raise it so the client knows something went wrong.
|
| 320 |
raise
|
| 321 |
|
|
@@ -339,12 +339,12 @@ class RotatingClient:
|
|
| 339 |
"usage": stream.usage.dict() if hasattr(stream.usage, 'dict') else vars(stream.usage)
|
| 340 |
}
|
| 341 |
yield f"data: {json.dumps(final_usage_chunk)}\n\n"
|
| 342 |
-
lib_logger.info(f"Yielded final usage chunk for
|
| 343 |
except Exception as e:
|
| 344 |
lib_logger.error(f"Failed to create or yield final usage chunk: {e}")
|
| 345 |
|
| 346 |
await self.usage_manager.release_key(key, model)
|
| 347 |
-
lib_logger.info(f"STREAM FINISHED and lock released for
|
| 348 |
|
| 349 |
# Only send [DONE] if the stream completed naturally and the client is still there.
|
| 350 |
# This prevents sending [DONE] to a disconnected client or after an error.
|
|
@@ -525,7 +525,7 @@ class RotatingClient:
|
|
| 525 |
log_failure(api_key=current_cred, model=model, attempt=attempt + 1, error=e, request_headers=dict(request.headers) if request else {})
|
| 526 |
|
| 527 |
if request and await request.is_disconnected():
|
| 528 |
-
lib_logger.warning(f"Client disconnected. Aborting retries for
|
| 529 |
raise last_exception
|
| 530 |
|
| 531 |
classified_error = classify_error(e)
|
|
@@ -907,7 +907,7 @@ class RotatingClient:
|
|
| 907 |
if provider_instance:
|
| 908 |
for api_key in shuffled_keys:
|
| 909 |
try:
|
| 910 |
-
lib_logger.debug(f"Attempting to get models for {provider} with
|
| 911 |
models = await provider_instance.get_models(api_key, self.http_client)
|
| 912 |
lib_logger.info(f"Got {len(models)} models for provider: {provider}")
|
| 913 |
|
|
@@ -920,10 +920,10 @@ class RotatingClient:
|
|
| 920 |
return filtered_models
|
| 921 |
except Exception as e:
|
| 922 |
classified_error = classify_error(e)
|
| 923 |
-
lib_logger.debug(f"Failed to get models for provider {provider} with
|
| 924 |
-
continue # Try the next
|
| 925 |
-
|
| 926 |
-
lib_logger.error(f"Failed to get models for provider {provider} after trying all
|
| 927 |
return []
|
| 928 |
|
| 929 |
async def get_all_available_models(self, grouped: bool = True) -> Union[Dict[str, List[str]], List[str]]:
|
|
|
|
| 233 |
try:
|
| 234 |
while True:
|
| 235 |
if request and await request.is_disconnected():
|
| 236 |
+
lib_logger.info(f"Client disconnected. Aborting stream for credential ...{key[-6:]}.")
|
| 237 |
# Do not yield [DONE] because the client is gone.
|
| 238 |
# The 'finally' block will handle key release.
|
| 239 |
break
|
|
|
|
| 262 |
# This is a critical, typed error from litellm that signals a key failure.
|
| 263 |
# We do not try to parse it here. We wrap it and raise it immediately
|
| 264 |
# for the outer retry loop to handle.
|
| 265 |
+
lib_logger.warning(f"Caught a critical API error mid-stream: {type(e).__name__}. Signaling for credential rotation.")
|
| 266 |
raise StreamedAPIError("Provider error received in stream", data=e)
|
| 267 |
|
| 268 |
except Exception as e:
|
|
|
|
| 315 |
|
| 316 |
except Exception as e:
|
| 317 |
# Catch any other unexpected errors during streaming.
|
| 318 |
+
lib_logger.error(f"An unexpected error occurred during the stream for credential ...{key[-6:]}: {e}")
|
| 319 |
# We still need to raise it so the client knows something went wrong.
|
| 320 |
raise
|
| 321 |
|
|
|
|
| 339 |
"usage": stream.usage.dict() if hasattr(stream.usage, 'dict') else vars(stream.usage)
|
| 340 |
}
|
| 341 |
yield f"data: {json.dumps(final_usage_chunk)}\n\n"
|
| 342 |
+
lib_logger.info(f"Yielded final usage chunk for credential ...{key[-6:]}.")
|
| 343 |
except Exception as e:
|
| 344 |
lib_logger.error(f"Failed to create or yield final usage chunk: {e}")
|
| 345 |
|
| 346 |
await self.usage_manager.release_key(key, model)
|
| 347 |
+
lib_logger.info(f"STREAM FINISHED and lock released for credential ...{key[-6:]}.")
|
| 348 |
|
| 349 |
# Only send [DONE] if the stream completed naturally and the client is still there.
|
| 350 |
# This prevents sending [DONE] to a disconnected client or after an error.
|
|
|
|
| 525 |
log_failure(api_key=current_cred, model=model, attempt=attempt + 1, error=e, request_headers=dict(request.headers) if request else {})
|
| 526 |
|
| 527 |
if request and await request.is_disconnected():
|
| 528 |
+
lib_logger.warning(f"Client disconnected. Aborting retries for credential ...{current_cred[-6:]}.")
|
| 529 |
raise last_exception
|
| 530 |
|
| 531 |
classified_error = classify_error(e)
|
|
|
|
| 907 |
if provider_instance:
|
| 908 |
for api_key in shuffled_keys:
|
| 909 |
try:
|
| 910 |
+
lib_logger.debug(f"Attempting to get models for {provider} with credential ...{api_key[-6:]}")
|
| 911 |
models = await provider_instance.get_models(api_key, self.http_client)
|
| 912 |
lib_logger.info(f"Got {len(models)} models for provider: {provider}")
|
| 913 |
|
|
|
|
| 920 |
return filtered_models
|
| 921 |
except Exception as e:
|
| 922 |
classified_error = classify_error(e)
|
| 923 |
+
lib_logger.debug(f"Failed to get models for provider {provider} with credential ...{api_key[-6:]}: {classified_error.error_type}. Trying next credential.")
|
| 924 |
+
continue # Try the next credential
|
| 925 |
+
|
| 926 |
+
lib_logger.error(f"Failed to get models for provider {provider} after trying all credentials.")
|
| 927 |
return []
|
| 928 |
|
| 929 |
async def get_all_available_models(self, grouped: bool = True) -> Union[Dict[str, List[str]], List[str]]:
|
src/rotator_library/credential_manager.py
CHANGED
|
@@ -11,8 +11,8 @@ OAUTH_BASE_DIR.mkdir(exist_ok=True)
|
|
| 11 |
|
| 12 |
# Standard paths where tools like `gemini login` store credentials.
|
| 13 |
DEFAULT_OAUTH_PATHS = {
|
| 14 |
-
"
|
| 15 |
-
"
|
| 16 |
# Add other providers like 'claude' here if they have a standard CLI path
|
| 17 |
}
|
| 18 |
|
|
@@ -31,6 +31,7 @@ class CredentialManager:
|
|
| 31 |
locally if it doesn't already exist and returns the updated config
|
| 32 |
pointing to the local paths.
|
| 33 |
"""
|
|
|
|
| 34 |
updated_config = {}
|
| 35 |
for provider, paths in self.oauth_config.items():
|
| 36 |
updated_paths = []
|
|
@@ -38,8 +39,13 @@ class CredentialManager:
|
|
| 38 |
account_id = i + 1
|
| 39 |
source_path = self._resolve_source_path(provider, path_str)
|
| 40 |
|
| 41 |
-
if not source_path
|
| 42 |
-
lib_logger.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
continue
|
| 44 |
|
| 45 |
local_filename = f"{provider}_oauth_{account_id}.json"
|
|
@@ -50,21 +56,32 @@ class CredentialManager:
|
|
| 50 |
shutil.copy(source_path, local_path)
|
| 51 |
lib_logger.info(f"Copied '{source_path}' to local credentials at '{local_path}'.")
|
| 52 |
except Exception as e:
|
| 53 |
-
lib_logger.error(f"Failed to copy OAuth file for {provider} account #{account_id}: {e}")
|
| 54 |
continue
|
| 55 |
|
| 56 |
updated_paths.append(str(local_path.resolve()))
|
| 57 |
|
| 58 |
if updated_paths:
|
|
|
|
| 59 |
updated_config[provider] = updated_paths
|
|
|
|
|
|
|
| 60 |
|
|
|
|
| 61 |
return updated_config
|
| 62 |
|
| 63 |
def _resolve_source_path(self, provider: str, specified_path: Optional[str]) -> Optional[Path]:
|
| 64 |
"""Determines the source path for a credential file."""
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
# If a path is given, use it directly.
|
| 67 |
-
return Path(
|
| 68 |
|
| 69 |
# If no path is given, try the default location.
|
| 70 |
return DEFAULT_OAUTH_PATHS.get(provider)
|
|
|
|
| 11 |
|
| 12 |
# Standard paths where tools like `gemini login` store credentials.
|
| 13 |
DEFAULT_OAUTH_PATHS = {
|
| 14 |
+
"gemini_cli": Path.home() / ".gemini" / "oauth_creds.json",
|
| 15 |
+
"qwen_code": Path.home() / ".qwen" / "oauth_creds.json",
|
| 16 |
# Add other providers like 'claude' here if they have a standard CLI path
|
| 17 |
}
|
| 18 |
|
|
|
|
| 31 |
locally if it doesn't already exist and returns the updated config
|
| 32 |
pointing to the local paths.
|
| 33 |
"""
|
| 34 |
+
lib_logger.info("Starting OAuth credential discovery...")
|
| 35 |
updated_config = {}
|
| 36 |
for provider, paths in self.oauth_config.items():
|
| 37 |
updated_paths = []
|
|
|
|
| 39 |
account_id = i + 1
|
| 40 |
source_path = self._resolve_source_path(provider, path_str)
|
| 41 |
|
| 42 |
+
if not source_path:
|
| 43 |
+
lib_logger.debug(f"No default path configured for provider: {provider}. Skipping account #{account_id}.")
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
lib_logger.debug(f"Checking for OAuth source file at '{source_path}'...")
|
| 47 |
+
if not source_path.exists():
|
| 48 |
+
lib_logger.debug(f"Could not find OAuth source file for {provider} account #{account_id}. Skipping.")
|
| 49 |
continue
|
| 50 |
|
| 51 |
local_filename = f"{provider}_oauth_{account_id}.json"
|
|
|
|
| 56 |
shutil.copy(source_path, local_path)
|
| 57 |
lib_logger.info(f"Copied '{source_path}' to local credentials at '{local_path}'.")
|
| 58 |
except Exception as e:
|
| 59 |
+
lib_logger.error(f"Failed to copy OAuth file for {provider} account #{account_id} from '{source_path}': {e}")
|
| 60 |
continue
|
| 61 |
|
| 62 |
updated_paths.append(str(local_path.resolve()))
|
| 63 |
|
| 64 |
if updated_paths:
|
| 65 |
+
lib_logger.info(f"Found and prepared {len(updated_paths)} credential(s) for provider: {provider}")
|
| 66 |
updated_config[provider] = updated_paths
|
| 67 |
+
else:
|
| 68 |
+
lib_logger.warning(f"No valid OAuth credentials found for configured provider: {provider}")
|
| 69 |
|
| 70 |
+
lib_logger.info("OAuth credential discovery complete.")
|
| 71 |
return updated_config
|
| 72 |
|
| 73 |
def _resolve_source_path(self, provider: str, specified_path: Optional[str]) -> Optional[Path]:
|
| 74 |
"""Determines the source path for a credential file."""
|
| 75 |
+
path_val = specified_path or ""
|
| 76 |
+
|
| 77 |
+
# Strip comments and whitespace, then remove quotes
|
| 78 |
+
path_val = path_val.split('#')[0].strip()
|
| 79 |
+
if path_val.startswith('"') and path_val.endswith('"'):
|
| 80 |
+
path_val = path_val[1:-1].strip()
|
| 81 |
+
|
| 82 |
+
if path_val:
|
| 83 |
# If a path is given, use it directly.
|
| 84 |
+
return Path(path_val).expanduser()
|
| 85 |
|
| 86 |
# If no path is given, try the default location.
|
| 87 |
return DEFAULT_OAUTH_PATHS.get(provider)
|
src/rotator_library/providers/gemini_auth_base.py
CHANGED
|
@@ -31,6 +31,7 @@ class GeminiAuthBase:
|
|
| 31 |
if path in self._credentials_cache:
|
| 32 |
return self._credentials_cache[path]
|
| 33 |
try:
|
|
|
|
| 34 |
with open(path, 'r') as f:
|
| 35 |
creds = json.load(f)
|
| 36 |
# Handle gcloud-style creds file which nest tokens under "credential"
|
|
@@ -38,6 +39,8 @@ class GeminiAuthBase:
|
|
| 38 |
creds = creds["credential"]
|
| 39 |
self._credentials_cache[path] = creds
|
| 40 |
return creds
|
|
|
|
|
|
|
| 41 |
except Exception as e:
|
| 42 |
raise IOError(f"Failed to load Gemini OAuth credentials from '{path}': {e}")
|
| 43 |
|
|
@@ -46,6 +49,7 @@ class GeminiAuthBase:
|
|
| 46 |
try:
|
| 47 |
with open(path, 'w') as f:
|
| 48 |
json.dump(creds, f, indent=2)
|
|
|
|
| 49 |
except Exception as e:
|
| 50 |
lib_logger.error(f"Failed to save updated Gemini OAuth credentials to '{path}': {e}")
|
| 51 |
|
|
@@ -101,10 +105,18 @@ class GeminiAuthBase:
|
|
| 101 |
# [NEW] Add init flow for invalid/expired tokens
|
| 102 |
async def initialize_token(self, path: str) -> Dict[str, Any]:
|
| 103 |
"""Initiates OAuth flow if tokens are missing or invalid."""
|
|
|
|
| 104 |
try:
|
| 105 |
creds = await self._load_credentials(path)
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
# Use subprocess to run gemini-cli setup or simulate web flow
|
| 109 |
# Based on CLIProxyAPI-main/gemini/gemini_auth.go: Use web flow with local server
|
| 110 |
# For simplicity, prompt user to run manual setup or integrate browser flow
|
|
@@ -123,6 +135,7 @@ class GeminiAuthBase:
|
|
| 123 |
print(f"Please open this URL in your browser:\n\n{auth_url}\n")
|
| 124 |
auth_code = input("After authorizing, paste the 'code' from the redirected URL here: ")
|
| 125 |
|
|
|
|
| 126 |
async with httpx.AsyncClient() as client:
|
| 127 |
response = await client.post(TOKEN_URI, data={
|
| 128 |
"code": auth_code.strip(),
|
|
@@ -141,11 +154,13 @@ class GeminiAuthBase:
|
|
| 141 |
"client_secret": CLIENT_SECRET
|
| 142 |
}
|
| 143 |
await self._save_credentials(path, creds)
|
| 144 |
-
lib_logger.info(f"Gemini OAuth initialized successfully for '{path}'.")
|
| 145 |
return creds
|
|
|
|
|
|
|
| 146 |
return creds
|
| 147 |
except Exception as e:
|
| 148 |
-
raise ValueError(f"Failed to initialize Gemini OAuth: {e}")
|
| 149 |
|
| 150 |
async def get_auth_header(self, credential_path: str) -> Dict[str, str]:
|
| 151 |
creds = await self._load_credentials(credential_path)
|
|
|
|
| 31 |
if path in self._credentials_cache:
|
| 32 |
return self._credentials_cache[path]
|
| 33 |
try:
|
| 34 |
+
lib_logger.debug(f"Loading Gemini credentials from file: {path}")
|
| 35 |
with open(path, 'r') as f:
|
| 36 |
creds = json.load(f)
|
| 37 |
# Handle gcloud-style creds file which nest tokens under "credential"
|
|
|
|
| 39 |
creds = creds["credential"]
|
| 40 |
self._credentials_cache[path] = creds
|
| 41 |
return creds
|
| 42 |
+
except FileNotFoundError:
|
| 43 |
+
raise IOError(f"Gemini OAuth credential file not found at '{path}'")
|
| 44 |
except Exception as e:
|
| 45 |
raise IOError(f"Failed to load Gemini OAuth credentials from '{path}': {e}")
|
| 46 |
|
|
|
|
| 49 |
try:
|
| 50 |
with open(path, 'w') as f:
|
| 51 |
json.dump(creds, f, indent=2)
|
| 52 |
+
lib_logger.debug(f"Saved updated Gemini OAuth credentials to '{path}'.")
|
| 53 |
except Exception as e:
|
| 54 |
lib_logger.error(f"Failed to save updated Gemini OAuth credentials to '{path}': {e}")
|
| 55 |
|
|
|
|
| 105 |
# [NEW] Add init flow for invalid/expired tokens
|
| 106 |
async def initialize_token(self, path: str) -> Dict[str, Any]:
|
| 107 |
"""Initiates OAuth flow if tokens are missing or invalid."""
|
| 108 |
+
lib_logger.debug(f"Initializing Gemini token at '{path}'...")
|
| 109 |
try:
|
| 110 |
creds = await self._load_credentials(path)
|
| 111 |
+
|
| 112 |
+
reason = ""
|
| 113 |
+
if not creds.get("refresh_token"):
|
| 114 |
+
reason = "refresh token is missing"
|
| 115 |
+
elif self._is_token_expired(creds):
|
| 116 |
+
reason = "token is expired"
|
| 117 |
+
|
| 118 |
+
if reason:
|
| 119 |
+
lib_logger.warning(f"Gemini OAuth token for '{Path(path).name}' needs setup: {reason}.")
|
| 120 |
# Use subprocess to run gemini-cli setup or simulate web flow
|
| 121 |
# Based on CLIProxyAPI-main/gemini/gemini_auth.go: Use web flow with local server
|
| 122 |
# For simplicity, prompt user to run manual setup or integrate browser flow
|
|
|
|
| 135 |
print(f"Please open this URL in your browser:\n\n{auth_url}\n")
|
| 136 |
auth_code = input("After authorizing, paste the 'code' from the redirected URL here: ")
|
| 137 |
|
| 138 |
+
lib_logger.info(f"Attempting to exchange authorization code for tokens...")
|
| 139 |
async with httpx.AsyncClient() as client:
|
| 140 |
response = await client.post(TOKEN_URI, data={
|
| 141 |
"code": auth_code.strip(),
|
|
|
|
| 154 |
"client_secret": CLIENT_SECRET
|
| 155 |
}
|
| 156 |
await self._save_credentials(path, creds)
|
| 157 |
+
lib_logger.info(f"Gemini OAuth initialized successfully for '{Path(path).name}'.")
|
| 158 |
return creds
|
| 159 |
+
|
| 160 |
+
lib_logger.info(f"Gemini OAuth token at '{Path(path).name}' is valid.")
|
| 161 |
return creds
|
| 162 |
except Exception as e:
|
| 163 |
+
raise ValueError(f"Failed to initialize Gemini OAuth for '{path}': {e}")
|
| 164 |
|
| 165 |
async def get_auth_header(self, credential_path: str) -> Dict[str, str]:
|
| 166 |
creds = await self._load_credentials(credential_path)
|
src/rotator_library/providers/qwen_auth_base.py
CHANGED
|
@@ -31,10 +31,13 @@ class QwenAuthBase:
|
|
| 31 |
if path in self._credentials_cache:
|
| 32 |
return self._credentials_cache[path]
|
| 33 |
try:
|
|
|
|
| 34 |
with open(path, 'r') as f:
|
| 35 |
creds = json.load(f)
|
| 36 |
self._credentials_cache[path] = creds
|
| 37 |
return creds
|
|
|
|
|
|
|
| 38 |
except Exception as e:
|
| 39 |
raise IOError(f"Failed to load Qwen OAuth credentials from '{path}': {e}")
|
| 40 |
|
|
@@ -43,6 +46,7 @@ class QwenAuthBase:
|
|
| 43 |
try:
|
| 44 |
with open(path, 'w') as f:
|
| 45 |
json.dump(creds, f, indent=2)
|
|
|
|
| 46 |
except Exception as e:
|
| 47 |
lib_logger.error(f"Failed to save updated Qwen OAuth credentials to '{path}': {e}")
|
| 48 |
|
|
@@ -100,10 +104,18 @@ class QwenAuthBase:
|
|
| 100 |
# [NEW] Add init flow for invalid/expired tokens
|
| 101 |
async def initialize_token(self, path: str) -> Dict[str, Any]:
|
| 102 |
"""Initiates device flow if tokens are missing or invalid."""
|
|
|
|
| 103 |
try:
|
| 104 |
creds = await self._load_credentials(path)
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# Based on CLIProxyAPI-main/qwen/qwen_auth.go: Use device code with PKCE
|
| 108 |
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
|
| 109 |
code_challenge = base64.urlsafe_b64encode(
|
|
@@ -126,6 +138,7 @@ class QwenAuthBase:
|
|
| 126 |
print(f"\n--- Qwen OAuth Setup Required for {Path(path).name} ---")
|
| 127 |
print(f"Please visit: {dev_data['verification_uri_complete']}")
|
| 128 |
print(f"And enter code: {dev_data['user_code']}\n")
|
|
|
|
| 129 |
|
| 130 |
token_data = None
|
| 131 |
start_time = time.time()
|
|
@@ -141,7 +154,9 @@ class QwenAuthBase:
|
|
| 141 |
)
|
| 142 |
if poll_response.status_code == 200:
|
| 143 |
token_data = poll_response.json()
|
|
|
|
| 144 |
break
|
|
|
|
| 145 |
await asyncio.sleep(dev_data['interval'])
|
| 146 |
|
| 147 |
if not token_data:
|
|
@@ -154,11 +169,13 @@ class QwenAuthBase:
|
|
| 154 |
"resource_url": token_data.get("resource_url")
|
| 155 |
})
|
| 156 |
await self._save_credentials(path, creds)
|
| 157 |
-
lib_logger.info(f"Qwen OAuth initialized successfully for '{path}'.")
|
| 158 |
return creds
|
|
|
|
|
|
|
| 159 |
return creds
|
| 160 |
except Exception as e:
|
| 161 |
-
raise ValueError(f"Failed to initialize Qwen OAuth: {e}")
|
| 162 |
|
| 163 |
async def get_auth_header(self, credential_path: str) -> Dict[str, str]:
|
| 164 |
creds = await self._load_credentials(credential_path)
|
|
|
|
| 31 |
if path in self._credentials_cache:
|
| 32 |
return self._credentials_cache[path]
|
| 33 |
try:
|
| 34 |
+
lib_logger.debug(f"Loading Qwen credentials from file: {path}")
|
| 35 |
with open(path, 'r') as f:
|
| 36 |
creds = json.load(f)
|
| 37 |
self._credentials_cache[path] = creds
|
| 38 |
return creds
|
| 39 |
+
except FileNotFoundError:
|
| 40 |
+
raise IOError(f"Qwen OAuth credential file not found at '{path}'")
|
| 41 |
except Exception as e:
|
| 42 |
raise IOError(f"Failed to load Qwen OAuth credentials from '{path}': {e}")
|
| 43 |
|
|
|
|
| 46 |
try:
|
| 47 |
with open(path, 'w') as f:
|
| 48 |
json.dump(creds, f, indent=2)
|
| 49 |
+
lib_logger.debug(f"Saved updated Qwen OAuth credentials to '{path}'.")
|
| 50 |
except Exception as e:
|
| 51 |
lib_logger.error(f"Failed to save updated Qwen OAuth credentials to '{path}': {e}")
|
| 52 |
|
|
|
|
| 104 |
# [NEW] Add init flow for invalid/expired tokens
|
| 105 |
async def initialize_token(self, path: str) -> Dict[str, Any]:
|
| 106 |
"""Initiates device flow if tokens are missing or invalid."""
|
| 107 |
+
lib_logger.debug(f"Initializing Qwen token at '{path}'...")
|
| 108 |
try:
|
| 109 |
creds = await self._load_credentials(path)
|
| 110 |
+
|
| 111 |
+
reason = ""
|
| 112 |
+
if not creds.get("refresh_token"):
|
| 113 |
+
reason = "refresh token is missing"
|
| 114 |
+
elif self._is_token_expired(creds):
|
| 115 |
+
reason = "token is expired"
|
| 116 |
+
|
| 117 |
+
if reason:
|
| 118 |
+
lib_logger.warning(f"Qwen OAuth token for '{Path(path).name}' needs setup: {reason}.")
|
| 119 |
# Based on CLIProxyAPI-main/qwen/qwen_auth.go: Use device code with PKCE
|
| 120 |
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
|
| 121 |
code_challenge = base64.urlsafe_b64encode(
|
|
|
|
| 138 |
print(f"\n--- Qwen OAuth Setup Required for {Path(path).name} ---")
|
| 139 |
print(f"Please visit: {dev_data['verification_uri_complete']}")
|
| 140 |
print(f"And enter code: {dev_data['user_code']}\n")
|
| 141 |
+
lib_logger.info("Polling for token, please complete authentication in the browser...")
|
| 142 |
|
| 143 |
token_data = None
|
| 144 |
start_time = time.time()
|
|
|
|
| 154 |
)
|
| 155 |
if poll_response.status_code == 200:
|
| 156 |
token_data = poll_response.json()
|
| 157 |
+
lib_logger.info("Successfully received token.")
|
| 158 |
break
|
| 159 |
+
lib_logger.debug("Polling for device code authentication...")
|
| 160 |
await asyncio.sleep(dev_data['interval'])
|
| 161 |
|
| 162 |
if not token_data:
|
|
|
|
| 169 |
"resource_url": token_data.get("resource_url")
|
| 170 |
})
|
| 171 |
await self._save_credentials(path, creds)
|
| 172 |
+
lib_logger.info(f"Qwen OAuth initialized successfully for '{Path(path).name}'.")
|
| 173 |
return creds
|
| 174 |
+
|
| 175 |
+
lib_logger.info(f"Qwen OAuth token at '{Path(path).name}' is valid.")
|
| 176 |
return creds
|
| 177 |
except Exception as e:
|
| 178 |
+
raise ValueError(f"Failed to initialize Qwen OAuth for '{path}': {e}")
|
| 179 |
|
| 180 |
async def get_auth_header(self, credential_path: str) -> Dict[str, str]:
|
| 181 |
creds = await self._load_credentials(credential_path)
|