Mirrowel commited on
Commit
622b036
·
1 Parent(s): f47e722

refactor(auth): enhance OAuth credential discovery and client logging

Browse files

standardize 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
- failed_inits = []
176
- for provider, paths in discovered_creds.items():
177
- provider_plugin_class = PROVIDER_PLUGINS.get(provider)
178
- if provider_plugin_class and hasattr(provider_plugin_class(), 'initialize_token'):
179
- provider_instance = provider_plugin_class()
180
- for path in paths:
181
- init_tasks.append(provider_instance.initialize_token(path))
 
182
 
183
  if init_tasks:
 
184
  # Run sequentially to allow for user input without overlap
185
- for task in init_tasks:
186
- await task
 
 
 
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 key ...{key[-4:]}.")
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 key rotation.")
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 key ...{key[-4:]}: {e}")
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 key ...{key[-4:]}.")
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 key ...{key[-4:]}.")
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 key ...{current_cred[-6:]}.")
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 key ...{api_key[-4:]}")
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 key ...{api_key[-4:]}: {classified_error.error_type}. Trying next key.")
924
- continue # Try the next key
925
-
926
- lib_logger.error(f"Failed to get models for provider {provider} after trying all keys.")
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
- "gemini": Path.home() / ".gemini" / "oauth_creds.json",
15
- "qwen": Path.home() / ".qwen" / "oauth_creds.json",
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 or not source_path.exists():
42
- lib_logger.warning(f"Could not find OAuth file for {provider} account #{account_id}. Skipping.")
 
 
 
 
 
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
- if specified_path:
 
 
 
 
 
 
 
66
  # If a path is given, use it directly.
67
- return Path(specified_path).expanduser()
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
- if not creds.get("refresh_token") or self._is_token_expired(creds):
107
- lib_logger.warning(f"Invalid or missing Gemini OAuth tokens at '{path}'. Initiating setup...")
 
 
 
 
 
 
 
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
- if not creds.get("refresh_token") or self._is_token_expired(creds):
106
- lib_logger.warning(f"Invalid or missing Qwen OAuth tokens at '{path}'. Initiating device flow...")
 
 
 
 
 
 
 
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)