ralphx 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/core/auth.py
CHANGED
|
@@ -128,7 +128,7 @@ class AuthStatus(BaseModel):
|
|
|
128
128
|
"""Authentication status."""
|
|
129
129
|
|
|
130
130
|
connected: bool
|
|
131
|
-
scope: Optional[Literal["project", "global"]] = None
|
|
131
|
+
scope: Optional[Literal["project", "global", "account"]] = None
|
|
132
132
|
email: Optional[str] = None # User's email address
|
|
133
133
|
subscription_type: Optional[str] = None
|
|
134
134
|
rate_limit_tier: Optional[str] = None
|
|
@@ -140,50 +140,44 @@ class AuthStatus(BaseModel):
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def get_auth_status(project_id: Optional[str] = None) -> AuthStatus:
|
|
143
|
-
"""Get auth status
|
|
143
|
+
"""Get auth status based on effective account for project.
|
|
144
144
|
|
|
145
|
-
Returns detailed status including whether project
|
|
145
|
+
Returns detailed status including whether project has a specific assignment.
|
|
146
146
|
"""
|
|
147
147
|
db = Database()
|
|
148
148
|
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
if project_id:
|
|
152
|
-
project_creds = db.get_credentials_by_scope("project", project_id)
|
|
149
|
+
# Get effective account (assigned to project, or default, or first active)
|
|
150
|
+
account = db.get_effective_account(project_id)
|
|
153
151
|
|
|
154
|
-
# Check
|
|
155
|
-
|
|
152
|
+
# Check if project has explicit assignment
|
|
153
|
+
has_project_assignment = False
|
|
154
|
+
if project_id:
|
|
155
|
+
assignment = db.get_project_account_assignment(project_id)
|
|
156
|
+
has_project_assignment = assignment is not None
|
|
156
157
|
|
|
157
|
-
#
|
|
158
|
-
|
|
158
|
+
# Check if using default fallback
|
|
159
|
+
using_fallback = project_id is not None and not has_project_assignment and account is not None
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
using_fallback = (
|
|
162
|
-
project_id is not None
|
|
163
|
-
and project_creds is None
|
|
164
|
-
and global_creds is not None
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
if not creds:
|
|
161
|
+
if not account:
|
|
168
162
|
return AuthStatus(
|
|
169
163
|
connected=False,
|
|
170
|
-
has_project_credentials=
|
|
164
|
+
has_project_credentials=has_project_assignment,
|
|
171
165
|
)
|
|
172
166
|
|
|
173
167
|
# Check expiry
|
|
174
168
|
now = int(time.time())
|
|
175
|
-
expires_at =
|
|
169
|
+
expires_at = account["expires_at"]
|
|
176
170
|
is_expired = now >= expires_at
|
|
177
171
|
|
|
178
172
|
return AuthStatus(
|
|
179
173
|
connected=True,
|
|
180
|
-
scope=
|
|
181
|
-
email=
|
|
174
|
+
scope="account", # New: accounts don't have scope like old credentials
|
|
175
|
+
email=account.get("email"),
|
|
182
176
|
expires_at=datetime.fromtimestamp(expires_at),
|
|
183
177
|
expires_in_seconds=max(0, expires_at - now),
|
|
184
178
|
is_expired=is_expired,
|
|
185
179
|
using_global_fallback=using_fallback,
|
|
186
|
-
has_project_credentials=
|
|
180
|
+
has_project_credentials=has_project_assignment,
|
|
187
181
|
)
|
|
188
182
|
|
|
189
183
|
|
|
@@ -215,17 +209,25 @@ async def _token_refresh_lock() -> AsyncGenerator[None, None]:
|
|
|
215
209
|
|
|
216
210
|
def store_oauth_tokens(
|
|
217
211
|
tokens: dict,
|
|
218
|
-
scope: Literal["project", "global"],
|
|
219
212
|
project_id: Optional[str] = None,
|
|
220
|
-
) ->
|
|
221
|
-
"""Store OAuth tokens in database.
|
|
213
|
+
) -> dict:
|
|
214
|
+
"""Store OAuth tokens in database (accounts table).
|
|
222
215
|
|
|
223
216
|
Args:
|
|
224
|
-
tokens: Dict with access_token, refresh_token, expires_in, email (
|
|
217
|
+
tokens: Dict with access_token, refresh_token, expires_in, email (required),
|
|
225
218
|
scopes (optional), subscription_type (optional), rate_limit_tier (optional)
|
|
226
|
-
|
|
227
|
-
|
|
219
|
+
project_id: Optional project ID to assign this account to
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Account dict with id, email, and other fields
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ValueError: If email not provided in tokens
|
|
228
226
|
"""
|
|
227
|
+
email = tokens.get("email")
|
|
228
|
+
if not email:
|
|
229
|
+
raise ValueError("Email is required to store OAuth tokens")
|
|
230
|
+
|
|
229
231
|
db = Database()
|
|
230
232
|
expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
231
233
|
|
|
@@ -234,43 +236,210 @@ def store_oauth_tokens(
|
|
|
234
236
|
if tokens.get("scopes"):
|
|
235
237
|
scopes_json = json.dumps(tokens["scopes"])
|
|
236
238
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
# Create or update account
|
|
240
|
+
account = db.create_account(
|
|
241
|
+
email=email,
|
|
239
242
|
access_token=tokens["access_token"],
|
|
240
243
|
refresh_token=tokens.get("refresh_token"),
|
|
241
244
|
expires_at=expires_at,
|
|
242
|
-
project_id=project_id if scope == "project" else None,
|
|
243
|
-
email=tokens.get("email"),
|
|
244
245
|
scopes=scopes_json,
|
|
245
246
|
subscription_type=tokens.get("subscription_type"),
|
|
246
247
|
rate_limit_tier=tokens.get("rate_limit_tier"),
|
|
247
248
|
)
|
|
248
249
|
|
|
250
|
+
# If project_id provided, assign this account to the project
|
|
251
|
+
if project_id:
|
|
252
|
+
db.assign_account_to_project(project_id, account["id"])
|
|
253
|
+
|
|
249
254
|
auth_log.info(
|
|
250
255
|
"login",
|
|
251
|
-
f"Logged in
|
|
252
|
-
|
|
253
|
-
email=tokens.get("email"),
|
|
256
|
+
f"Logged in as {email}",
|
|
257
|
+
email=email,
|
|
254
258
|
project_id=project_id,
|
|
255
259
|
)
|
|
256
|
-
return
|
|
260
|
+
return account
|
|
261
|
+
|
|
257
262
|
|
|
263
|
+
def get_effective_account_for_project(project_id: Optional[str] = None) -> Optional[dict]:
|
|
264
|
+
"""Get the effective account for a project.
|
|
258
265
|
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
Resolution order:
|
|
267
|
+
1. If project has assignment -> use that account
|
|
268
|
+
2. Else -> use default account
|
|
269
|
+
3. If no default -> use first active account
|
|
261
270
|
|
|
262
271
|
Args:
|
|
263
|
-
|
|
272
|
+
project_id: Optional project ID
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Account dict or None
|
|
276
|
+
"""
|
|
277
|
+
db = Database()
|
|
278
|
+
return db.get_effective_account(project_id)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_fallback_account_for_rate_limit(
|
|
282
|
+
current_account_id: int,
|
|
283
|
+
failed_account_ids: Optional[list[int]] = None,
|
|
284
|
+
) -> Optional[dict]:
|
|
285
|
+
"""Get a fallback account after hitting rate limit (429).
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
current_account_id: Account that hit the rate limit
|
|
289
|
+
failed_account_ids: List of account IDs that already failed
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Account dict for fallback, or None if no fallback available
|
|
293
|
+
"""
|
|
294
|
+
db = Database()
|
|
295
|
+
exclude_ids = [current_account_id] + (failed_account_ids or [])
|
|
296
|
+
return db.get_fallback_account(exclude_ids=exclude_ids, prefer_lowest_usage=True)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@contextmanager
|
|
300
|
+
def swap_credentials_for_account(
|
|
301
|
+
account: dict,
|
|
302
|
+
) -> Generator[bool, None, None]:
|
|
303
|
+
"""Context manager: backup user creds, write account creds, restore after.
|
|
304
|
+
|
|
305
|
+
Similar to swap_credentials_for_loop but takes an account dict directly.
|
|
306
|
+
Used when we have a specific account to use (e.g., from fallback logic).
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
account: Account dict with access_token, refresh_token, etc.
|
|
310
|
+
|
|
311
|
+
Yields:
|
|
312
|
+
True if credentials were written, False if account invalid
|
|
313
|
+
"""
|
|
314
|
+
global _credential_swap_active
|
|
315
|
+
|
|
316
|
+
db = Database()
|
|
317
|
+
|
|
318
|
+
# Acquire exclusive lock to prevent concurrent credential access
|
|
319
|
+
CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
lock_file = open(CREDENTIAL_LOCK_PATH, "w")
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) # Exclusive lock
|
|
324
|
+
|
|
325
|
+
# Backup user's current credentials
|
|
326
|
+
had_backup = False
|
|
327
|
+
original_content = None
|
|
328
|
+
if CLAUDE_CREDENTIALS_PATH.exists():
|
|
329
|
+
original_content = CLAUDE_CREDENTIALS_PATH.read_text()
|
|
330
|
+
shutil.copy2(CLAUDE_CREDENTIALS_PATH, CLAUDE_CREDENTIALS_BACKUP)
|
|
331
|
+
had_backup = True
|
|
332
|
+
|
|
333
|
+
_credential_swap_active = True
|
|
334
|
+
|
|
335
|
+
# Write account credentials to Claude's location
|
|
336
|
+
has_creds = False
|
|
337
|
+
if account and account.get("access_token"):
|
|
338
|
+
default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
|
|
339
|
+
stored_scopes = account.get("scopes")
|
|
340
|
+
if stored_scopes:
|
|
341
|
+
try:
|
|
342
|
+
scopes = json.loads(stored_scopes)
|
|
343
|
+
except (json.JSONDecodeError, TypeError):
|
|
344
|
+
scopes = default_scopes
|
|
345
|
+
else:
|
|
346
|
+
scopes = default_scopes
|
|
347
|
+
|
|
348
|
+
creds_data = {
|
|
349
|
+
"claudeAiOauth": {
|
|
350
|
+
"accessToken": account["access_token"],
|
|
351
|
+
"refreshToken": account.get("refresh_token"),
|
|
352
|
+
"expiresAt": account.get("expires_at", 0) * 1000,
|
|
353
|
+
"scopes": scopes,
|
|
354
|
+
"subscriptionType": account.get("subscription_type") or "max",
|
|
355
|
+
"rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
|
|
360
|
+
os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
|
|
361
|
+
has_creds = True
|
|
362
|
+
|
|
363
|
+
# Update last_used_at for the account
|
|
364
|
+
db.update_account(account["id"], last_used_at=datetime.utcnow().isoformat())
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
yield has_creds
|
|
368
|
+
finally:
|
|
369
|
+
# Capture any token refresh that happened during execution
|
|
370
|
+
if has_creds and CLAUDE_CREDENTIALS_PATH.exists():
|
|
371
|
+
try:
|
|
372
|
+
current_creds = json.loads(CLAUDE_CREDENTIALS_PATH.read_text())
|
|
373
|
+
oauth = current_creds.get("claudeAiOauth", {})
|
|
374
|
+
new_refresh = oauth.get("refreshToken")
|
|
375
|
+
|
|
376
|
+
if new_refresh and new_refresh != account.get("refresh_token"):
|
|
377
|
+
db.update_account(
|
|
378
|
+
account["id"],
|
|
379
|
+
access_token=oauth.get("accessToken"),
|
|
380
|
+
refresh_token=new_refresh,
|
|
381
|
+
expires_at=int(oauth.get("expiresAt", 0) / 1000),
|
|
382
|
+
)
|
|
383
|
+
auth_log.info(
|
|
384
|
+
"account_token_captured",
|
|
385
|
+
f"Captured refreshed token for account {account.get('email')}",
|
|
386
|
+
account_id=account["id"],
|
|
387
|
+
)
|
|
388
|
+
except Exception as e:
|
|
389
|
+
auth_log.warning(
|
|
390
|
+
"account_token_capture_failed",
|
|
391
|
+
f"Failed to capture refreshed token for account: {e}",
|
|
392
|
+
account_id=account.get("id"),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Restore user's original credentials
|
|
396
|
+
restoration_success = False
|
|
397
|
+
try:
|
|
398
|
+
if had_backup and CLAUDE_CREDENTIALS_BACKUP.exists():
|
|
399
|
+
shutil.copy2(CLAUDE_CREDENTIALS_BACKUP, CLAUDE_CREDENTIALS_PATH)
|
|
400
|
+
restoration_success = True
|
|
401
|
+
elif not had_backup and has_creds:
|
|
402
|
+
CLAUDE_CREDENTIALS_PATH.unlink(missing_ok=True)
|
|
403
|
+
restoration_success = True
|
|
404
|
+
else:
|
|
405
|
+
restoration_success = True
|
|
406
|
+
except Exception as e:
|
|
407
|
+
auth_log.error(
|
|
408
|
+
"account_restoration_failed",
|
|
409
|
+
f"Failed to restore credentials after account swap: {e}",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if restoration_success and CLAUDE_CREDENTIALS_BACKUP.exists():
|
|
413
|
+
try:
|
|
414
|
+
CLAUDE_CREDENTIALS_BACKUP.unlink()
|
|
415
|
+
except Exception:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
_credential_swap_active = False
|
|
419
|
+
finally:
|
|
420
|
+
_credential_swap_active = False
|
|
421
|
+
try:
|
|
422
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
423
|
+
lock_file.close()
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _do_token_refresh(account: dict, project_id: Optional[str] = None) -> bool:
|
|
429
|
+
"""Actually perform the token refresh via OAuth for an account.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
account: Account dict from database with id, refresh_token, email
|
|
264
433
|
project_id: Optional project ID for logging
|
|
265
434
|
|
|
266
435
|
Returns:
|
|
267
436
|
True if refresh succeeded, False otherwise
|
|
268
437
|
"""
|
|
269
|
-
if not
|
|
438
|
+
if not account.get("refresh_token"):
|
|
270
439
|
auth_log.warning(
|
|
271
440
|
"token_refresh_failed",
|
|
272
|
-
f"No refresh token available
|
|
273
|
-
|
|
441
|
+
f"No refresh token available for account {account.get('email', 'unknown')}",
|
|
442
|
+
email=account.get("email"),
|
|
274
443
|
project_id=project_id,
|
|
275
444
|
)
|
|
276
445
|
return False
|
|
@@ -282,7 +451,7 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
282
451
|
TOKEN_URL,
|
|
283
452
|
json={
|
|
284
453
|
"grant_type": "refresh_token",
|
|
285
|
-
"refresh_token":
|
|
454
|
+
"refresh_token": account["refresh_token"],
|
|
286
455
|
"client_id": CLIENT_ID,
|
|
287
456
|
},
|
|
288
457
|
headers={
|
|
@@ -295,8 +464,9 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
295
464
|
error_body = resp.text[:200] if resp.text else "no response body"
|
|
296
465
|
auth_log.warning(
|
|
297
466
|
"token_refresh_failed",
|
|
298
|
-
f"Token refresh failed (
|
|
299
|
-
|
|
467
|
+
f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code} - {error_body}",
|
|
468
|
+
email=account.get("email"),
|
|
469
|
+
account_id=account.get("id"),
|
|
300
470
|
project_id=project_id,
|
|
301
471
|
status_code=resp.status_code,
|
|
302
472
|
)
|
|
@@ -304,6 +474,8 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
304
474
|
try:
|
|
305
475
|
error_json = resp.json()
|
|
306
476
|
if error_json.get("error") == "invalid_grant":
|
|
477
|
+
# Mark account as inactive since token is revoked
|
|
478
|
+
db.update_account(account["id"], is_active=False, last_error="invalid_grant")
|
|
307
479
|
raise InvalidGrantError("Refresh token expired or revoked. Please re-login.")
|
|
308
480
|
except (ValueError, KeyError):
|
|
309
481
|
pass
|
|
@@ -312,33 +484,37 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
312
484
|
tokens = resp.json()
|
|
313
485
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
314
486
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
487
|
+
# Build update dict - capture subscription/plan info if present
|
|
488
|
+
update_data = {
|
|
489
|
+
"access_token": tokens["access_token"],
|
|
490
|
+
"refresh_token": tokens.get("refresh_token", account["refresh_token"]),
|
|
491
|
+
"expires_at": new_expires_at,
|
|
492
|
+
"consecutive_failures": 0, # Reset failure count on success
|
|
493
|
+
}
|
|
494
|
+
if tokens.get("subscription_type"):
|
|
495
|
+
update_data["subscription_type"] = tokens["subscription_type"]
|
|
496
|
+
if tokens.get("rate_limit_tier"):
|
|
497
|
+
update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
|
|
318
498
|
|
|
319
499
|
# CRITICAL: If DB update fails after refresh, we've consumed the
|
|
320
500
|
# refresh_token but not saved the new one. Log this clearly.
|
|
321
501
|
try:
|
|
322
|
-
db.
|
|
323
|
-
creds["id"],
|
|
324
|
-
access_token=tokens["access_token"],
|
|
325
|
-
refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
|
|
326
|
-
expires_at=new_expires_at,
|
|
327
|
-
email=email if email else creds.get("email"),
|
|
328
|
-
)
|
|
502
|
+
db.update_account(account["id"], **update_data)
|
|
329
503
|
except Exception as db_error:
|
|
330
504
|
auth_log.error(
|
|
331
505
|
"token_db_update_failed",
|
|
332
|
-
f"Failed to save refreshed token (
|
|
333
|
-
|
|
506
|
+
f"Failed to save refreshed token for {account.get('email')}: {db_error}. Token may be lost!",
|
|
507
|
+
email=account.get("email"),
|
|
508
|
+
account_id=account.get("id"),
|
|
334
509
|
project_id=project_id,
|
|
335
510
|
)
|
|
336
511
|
return False
|
|
337
512
|
|
|
338
513
|
auth_log.info(
|
|
339
514
|
"token_refresh",
|
|
340
|
-
f"Token refreshed (
|
|
341
|
-
|
|
515
|
+
f"Token refreshed for {account.get('email')}",
|
|
516
|
+
email=account.get("email"),
|
|
517
|
+
account_id=account.get("id"),
|
|
342
518
|
project_id=project_id,
|
|
343
519
|
expires_in=tokens.get("expires_in", 28800),
|
|
344
520
|
)
|
|
@@ -346,53 +522,55 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
346
522
|
except httpx.HTTPError as http_error:
|
|
347
523
|
auth_log.warning(
|
|
348
524
|
"token_refresh_failed",
|
|
349
|
-
f"Token refresh HTTP error (
|
|
350
|
-
|
|
525
|
+
f"Token refresh HTTP error for {account.get('email')}: {http_error}",
|
|
526
|
+
email=account.get("email"),
|
|
527
|
+
account_id=account.get("id"),
|
|
351
528
|
project_id=project_id,
|
|
352
529
|
)
|
|
353
530
|
return False
|
|
354
531
|
except Exception as e:
|
|
355
532
|
auth_log.warning(
|
|
356
533
|
"token_refresh_failed",
|
|
357
|
-
f"Token refresh failed (
|
|
358
|
-
|
|
534
|
+
f"Token refresh failed for {account.get('email')}: {e}",
|
|
535
|
+
email=account.get("email"),
|
|
536
|
+
account_id=account.get("id"),
|
|
359
537
|
project_id=project_id,
|
|
360
538
|
)
|
|
361
539
|
return False
|
|
362
540
|
|
|
363
541
|
|
|
364
|
-
async def
|
|
365
|
-
"""Validate OAuth token by attempting a refresh.
|
|
542
|
+
async def validate_account_token(account: dict) -> tuple[bool, str]:
|
|
543
|
+
"""Validate account's OAuth token by attempting a refresh.
|
|
366
544
|
|
|
367
545
|
Since we're using OAuth tokens (not API keys), we validate by
|
|
368
546
|
trying to refresh the token. If the refresh_token is still valid,
|
|
369
547
|
we'll get a new access_token. If it's expired/revoked, we get an error.
|
|
370
548
|
|
|
371
549
|
Args:
|
|
372
|
-
|
|
550
|
+
account: Account dict with refresh_token
|
|
373
551
|
|
|
374
552
|
Returns:
|
|
375
553
|
Tuple of (is_valid, error_message)
|
|
376
554
|
"""
|
|
377
|
-
if not
|
|
378
|
-
return False, "No
|
|
555
|
+
if not account:
|
|
556
|
+
return False, "No account"
|
|
379
557
|
|
|
380
|
-
if not
|
|
558
|
+
if not account.get("refresh_token"):
|
|
381
559
|
return False, "No refresh token available"
|
|
382
560
|
|
|
383
561
|
try:
|
|
384
562
|
# Use lock to prevent concurrent refresh operations.
|
|
385
|
-
# Re-read
|
|
563
|
+
# Re-read account inside lock in case another process already refreshed.
|
|
386
564
|
async with _token_refresh_lock():
|
|
387
|
-
# Re-fetch
|
|
565
|
+
# Re-fetch account to get any updates from concurrent refresh
|
|
388
566
|
db = Database()
|
|
389
|
-
|
|
390
|
-
if
|
|
567
|
+
fresh_account = db.get_account(account["id"])
|
|
568
|
+
if fresh_account and fresh_account.get("refresh_token") != account.get("refresh_token"):
|
|
391
569
|
# Another process already refreshed - we have fresh tokens
|
|
392
570
|
auth_log.info(
|
|
393
571
|
"token_already_refreshed",
|
|
394
572
|
"Token was refreshed by another process",
|
|
395
|
-
|
|
573
|
+
email=account.get("email"),
|
|
396
574
|
)
|
|
397
575
|
return True, ""
|
|
398
576
|
|
|
@@ -402,7 +580,7 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
|
|
|
402
580
|
TOKEN_URL,
|
|
403
581
|
json={
|
|
404
582
|
"grant_type": "refresh_token",
|
|
405
|
-
"refresh_token":
|
|
583
|
+
"refresh_token": account["refresh_token"],
|
|
406
584
|
"client_id": CLIENT_ID,
|
|
407
585
|
},
|
|
408
586
|
headers={
|
|
@@ -415,32 +593,35 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
|
|
|
415
593
|
# Token is valid and we got a new one - update it
|
|
416
594
|
tokens = resp.json()
|
|
417
595
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
418
|
-
account = tokens.get("account", {})
|
|
419
|
-
email = account.get("email_address")
|
|
420
596
|
|
|
421
|
-
#
|
|
597
|
+
# Build update dict - capture subscription/plan info if present
|
|
598
|
+
update_data = {
|
|
599
|
+
"access_token": tokens["access_token"],
|
|
600
|
+
"refresh_token": tokens.get("refresh_token", account["refresh_token"]),
|
|
601
|
+
"expires_at": new_expires_at,
|
|
602
|
+
"consecutive_failures": 0,
|
|
603
|
+
}
|
|
604
|
+
if tokens.get("subscription_type"):
|
|
605
|
+
update_data["subscription_type"] = tokens["subscription_type"]
|
|
606
|
+
if tokens.get("rate_limit_tier"):
|
|
607
|
+
update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
|
|
608
|
+
|
|
422
609
|
# CRITICAL: If DB update fails, we've consumed the refresh_token
|
|
423
610
|
# but not saved the new one. Handle this carefully.
|
|
424
611
|
try:
|
|
425
|
-
db.
|
|
426
|
-
creds["id"],
|
|
427
|
-
access_token=tokens["access_token"],
|
|
428
|
-
refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
|
|
429
|
-
expires_at=new_expires_at,
|
|
430
|
-
email=email if email else creds.get("email"),
|
|
431
|
-
)
|
|
612
|
+
db.update_account(account["id"], **update_data)
|
|
432
613
|
except Exception as db_error:
|
|
433
614
|
auth_log.error(
|
|
434
615
|
"token_db_update_failed",
|
|
435
616
|
f"Failed to save refreshed token: {db_error}. Token may be lost!",
|
|
436
|
-
|
|
617
|
+
email=account.get("email"),
|
|
437
618
|
)
|
|
438
619
|
return False, f"Token refresh succeeded but failed to save: {db_error}"
|
|
439
620
|
|
|
440
621
|
auth_log.info(
|
|
441
622
|
"token_validated",
|
|
442
|
-
f"Token validated and refreshed
|
|
443
|
-
|
|
623
|
+
f"Token validated and refreshed for {account.get('email')}",
|
|
624
|
+
email=account.get("email"),
|
|
444
625
|
)
|
|
445
626
|
return True, ""
|
|
446
627
|
|
|
@@ -450,6 +631,8 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
|
|
|
450
631
|
error_type = error_json.get("error", "unknown")
|
|
451
632
|
error_desc = error_json.get("error_description", "")
|
|
452
633
|
if error_type == "invalid_grant":
|
|
634
|
+
# Mark account as needing re-auth
|
|
635
|
+
db.update_account(account["id"], is_active=False, last_error="invalid_grant")
|
|
453
636
|
return False, f"Refresh token expired or revoked: {error_desc}. Please re-login."
|
|
454
637
|
return False, f"OAuth error: {error_type} - {error_desc}"
|
|
455
638
|
except Exception:
|
|
@@ -493,65 +676,81 @@ async def refresh_token_if_needed(
|
|
|
493
676
|
"""Auto-refresh token if expired. Returns True if valid token available.
|
|
494
677
|
|
|
495
678
|
Args:
|
|
496
|
-
project_id: Optional project ID to
|
|
679
|
+
project_id: Optional project ID to get effective account for
|
|
497
680
|
validate: If True, actually validate the token by calling API
|
|
498
681
|
|
|
499
682
|
Returns:
|
|
500
|
-
True if valid
|
|
501
|
-
False if no
|
|
683
|
+
True if valid account exists (either not expired or successfully refreshed)
|
|
684
|
+
False if no account or refresh failed
|
|
502
685
|
"""
|
|
503
686
|
db = Database()
|
|
504
|
-
|
|
687
|
+
account = db.get_effective_account(project_id)
|
|
505
688
|
|
|
506
|
-
if not
|
|
689
|
+
if not account:
|
|
507
690
|
return False
|
|
508
691
|
|
|
509
692
|
now = int(time.time())
|
|
510
693
|
|
|
511
694
|
# If validation requested, actually test the token
|
|
512
|
-
# Note:
|
|
695
|
+
# Note: validate_account_token() attempts a refresh as part of validation.
|
|
513
696
|
# If it fails, the refresh already failed - no point retrying immediately.
|
|
514
697
|
if validate:
|
|
515
|
-
is_valid, error = await
|
|
698
|
+
is_valid, error = await validate_account_token(account)
|
|
516
699
|
if not is_valid:
|
|
517
700
|
auth_log.warning(
|
|
518
701
|
"token_invalid",
|
|
519
702
|
f"Token validation failed: {error}",
|
|
520
|
-
|
|
703
|
+
email=account.get("email"),
|
|
521
704
|
project_id=project_id,
|
|
522
705
|
)
|
|
523
|
-
# Don't retry -
|
|
706
|
+
# Don't retry - validate_account_token already tried to refresh.
|
|
524
707
|
# The error message explains what went wrong.
|
|
525
708
|
return False
|
|
526
709
|
return True
|
|
527
710
|
|
|
528
711
|
# Otherwise just check expiry timestamp (5 minute buffer)
|
|
529
|
-
if now <
|
|
712
|
+
if now < account["expires_at"] - 300:
|
|
530
713
|
return True # Token assumed valid based on expiry
|
|
531
714
|
|
|
532
|
-
|
|
715
|
+
# Use lock to prevent concurrent refresh operations.
|
|
716
|
+
# Re-read account inside lock in case another process already refreshed.
|
|
717
|
+
async with _token_refresh_lock():
|
|
718
|
+
# Re-fetch account to get latest token state
|
|
719
|
+
fresh_account = db.get_effective_account(project_id)
|
|
720
|
+
if not fresh_account:
|
|
721
|
+
return False
|
|
722
|
+
# Check if another process already refreshed while we waited for lock
|
|
723
|
+
if int(time.time()) < fresh_account["expires_at"] - 300:
|
|
724
|
+
return True
|
|
725
|
+
return await _do_token_refresh(fresh_account, project_id)
|
|
533
726
|
|
|
534
727
|
|
|
535
728
|
async def force_refresh_token(project_id: Optional[str] = None) -> dict:
|
|
536
729
|
"""Force refresh a token regardless of expiry time.
|
|
537
730
|
|
|
538
731
|
Args:
|
|
539
|
-
project_id: Optional project ID to
|
|
732
|
+
project_id: Optional project ID to get effective account for
|
|
540
733
|
|
|
541
734
|
Returns:
|
|
542
735
|
dict with success status and message
|
|
543
736
|
"""
|
|
544
737
|
db = Database()
|
|
545
|
-
|
|
738
|
+
account = db.get_effective_account(project_id)
|
|
546
739
|
|
|
547
|
-
if not
|
|
548
|
-
return {"success": False, "error": "No
|
|
740
|
+
if not account:
|
|
741
|
+
return {"success": False, "error": "No account found"}
|
|
549
742
|
|
|
550
|
-
if not
|
|
743
|
+
if not account.get("refresh_token"):
|
|
551
744
|
return {"success": False, "error": "No refresh token available"}
|
|
552
745
|
|
|
553
746
|
try:
|
|
554
|
-
|
|
747
|
+
# Use lock to prevent concurrent refresh operations.
|
|
748
|
+
# Re-read account inside lock in case another process already refreshed.
|
|
749
|
+
async with _token_refresh_lock():
|
|
750
|
+
fresh_account = db.get_effective_account(project_id)
|
|
751
|
+
if not fresh_account or not fresh_account.get("refresh_token"):
|
|
752
|
+
return {"success": False, "error": "No account or refresh token available"}
|
|
753
|
+
success = await _do_token_refresh(fresh_account, project_id)
|
|
555
754
|
if success:
|
|
556
755
|
return {"success": True, "message": "Token refreshed successfully"}
|
|
557
756
|
else:
|
|
@@ -561,7 +760,7 @@ async def force_refresh_token(project_id: Optional[str] = None) -> dict:
|
|
|
561
760
|
|
|
562
761
|
|
|
563
762
|
async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
564
|
-
"""Refresh all tokens expiring within buffer_seconds.
|
|
763
|
+
"""Refresh all account tokens expiring within buffer_seconds.
|
|
565
764
|
|
|
566
765
|
Called by background task to proactively keep tokens fresh.
|
|
567
766
|
|
|
@@ -575,35 +774,39 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
575
774
|
now = int(time.time())
|
|
576
775
|
result = {"checked": 0, "refreshed": 0, "failed": 0}
|
|
577
776
|
|
|
578
|
-
# Get all
|
|
579
|
-
|
|
777
|
+
# Get all active accounts
|
|
778
|
+
all_accounts = db.list_accounts(include_inactive=False, include_deleted=False)
|
|
580
779
|
|
|
581
|
-
for
|
|
780
|
+
for account in all_accounts:
|
|
582
781
|
result["checked"] += 1
|
|
583
782
|
|
|
584
783
|
# Skip if not expiring soon
|
|
585
|
-
if now <
|
|
784
|
+
if now < account["expires_at"] - buffer_seconds:
|
|
586
785
|
continue
|
|
587
786
|
|
|
588
787
|
# Skip if no refresh token
|
|
589
|
-
if not
|
|
788
|
+
if not account.get("refresh_token"):
|
|
590
789
|
continue
|
|
591
790
|
|
|
592
791
|
# Attempt refresh with lock to prevent race conditions
|
|
593
792
|
try:
|
|
594
793
|
async with _token_refresh_lock():
|
|
595
|
-
# Re-fetch
|
|
596
|
-
|
|
597
|
-
if
|
|
794
|
+
# Re-fetch account to check if another process already refreshed
|
|
795
|
+
fresh_account = db.get_account(account["id"])
|
|
796
|
+
if fresh_account and now < fresh_account["expires_at"] - buffer_seconds:
|
|
598
797
|
# Token was refreshed by another process, skip
|
|
599
798
|
continue
|
|
600
799
|
|
|
800
|
+
# Use fresh_account's refresh_token (not the pre-lock stale one)
|
|
801
|
+
# Another process may have refreshed and changed the token while we waited
|
|
802
|
+
refresh_token = fresh_account["refresh_token"] if fresh_account else account["refresh_token"]
|
|
803
|
+
|
|
601
804
|
async with httpx.AsyncClient() as client:
|
|
602
805
|
resp = await client.post(
|
|
603
806
|
TOKEN_URL,
|
|
604
807
|
json={
|
|
605
808
|
"grant_type": "refresh_token",
|
|
606
|
-
"refresh_token":
|
|
809
|
+
"refresh_token": refresh_token,
|
|
607
810
|
"client_id": CLIENT_ID,
|
|
608
811
|
},
|
|
609
812
|
headers={
|
|
@@ -615,72 +818,66 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
615
818
|
if resp.status_code == 200:
|
|
616
819
|
tokens = resp.json()
|
|
617
820
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
618
|
-
|
|
619
|
-
|
|
821
|
+
|
|
822
|
+
# Build update dict - capture subscription/plan info if present
|
|
823
|
+
# Use refresh_token (the fresh value used for this request) as fallback,
|
|
824
|
+
# not account["refresh_token"] which is the pre-lock stale copy
|
|
825
|
+
update_data = {
|
|
826
|
+
"access_token": tokens["access_token"],
|
|
827
|
+
"refresh_token": tokens.get("refresh_token", refresh_token),
|
|
828
|
+
"expires_at": new_expires_at,
|
|
829
|
+
"consecutive_failures": 0,
|
|
830
|
+
}
|
|
831
|
+
if tokens.get("subscription_type"):
|
|
832
|
+
update_data["subscription_type"] = tokens["subscription_type"]
|
|
833
|
+
if tokens.get("rate_limit_tier"):
|
|
834
|
+
update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
|
|
620
835
|
|
|
621
836
|
try:
|
|
622
|
-
db.
|
|
623
|
-
creds["id"],
|
|
624
|
-
access_token=tokens["access_token"],
|
|
625
|
-
refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
|
|
626
|
-
expires_at=new_expires_at,
|
|
627
|
-
email=email if email else creds.get("email"),
|
|
628
|
-
)
|
|
837
|
+
db.update_account(account["id"], **update_data)
|
|
629
838
|
result["refreshed"] += 1
|
|
630
839
|
auth_log.info(
|
|
631
840
|
"token_refresh",
|
|
632
|
-
f"Token refreshed (
|
|
633
|
-
|
|
634
|
-
|
|
841
|
+
f"Token refreshed for {account.get('email')}",
|
|
842
|
+
email=account.get("email"),
|
|
843
|
+
account_id=account.get("id"),
|
|
635
844
|
expires_in=tokens.get("expires_in", 28800),
|
|
636
845
|
)
|
|
637
846
|
except Exception as db_error:
|
|
638
847
|
result["failed"] += 1
|
|
639
848
|
auth_log.error(
|
|
640
849
|
"token_db_update_failed",
|
|
641
|
-
f"Failed to save refreshed token (
|
|
642
|
-
|
|
643
|
-
|
|
850
|
+
f"Failed to save refreshed token for {account.get('email')}: {db_error}",
|
|
851
|
+
email=account.get("email"),
|
|
852
|
+
account_id=account.get("id"),
|
|
644
853
|
)
|
|
645
854
|
else:
|
|
646
855
|
result["failed"] += 1
|
|
856
|
+
# Track consecutive failures (use fresh_account for accurate count)
|
|
857
|
+
failure_count = (fresh_account or account).get("consecutive_failures", 0)
|
|
858
|
+
db.update_account(
|
|
859
|
+
account["id"],
|
|
860
|
+
consecutive_failures=failure_count + 1,
|
|
861
|
+
)
|
|
647
862
|
auth_log.warning(
|
|
648
863
|
"token_refresh_failed",
|
|
649
|
-
f"Token refresh failed (
|
|
650
|
-
|
|
651
|
-
|
|
864
|
+
f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code}",
|
|
865
|
+
email=account.get("email"),
|
|
866
|
+
account_id=account.get("id"),
|
|
652
867
|
)
|
|
653
868
|
except Exception as e:
|
|
654
869
|
result["failed"] += 1
|
|
655
870
|
auth_log.error(
|
|
656
871
|
"token_refresh_error",
|
|
657
|
-
f"Token refresh error (
|
|
658
|
-
|
|
659
|
-
|
|
872
|
+
f"Token refresh error for {account.get('email')}: {e}",
|
|
873
|
+
email=account.get("email"),
|
|
874
|
+
account_id=account.get("id"),
|
|
660
875
|
error=str(e),
|
|
661
876
|
)
|
|
662
877
|
|
|
663
878
|
return result
|
|
664
879
|
|
|
665
880
|
|
|
666
|
-
def clear_credentials(
|
|
667
|
-
scope: Literal["project", "global"],
|
|
668
|
-
project_id: Optional[str] = None,
|
|
669
|
-
) -> bool:
|
|
670
|
-
"""Remove credentials for the specified scope from database."""
|
|
671
|
-
db = Database()
|
|
672
|
-
result = db.delete_credentials(scope, project_id if scope == "project" else None)
|
|
673
|
-
|
|
674
|
-
if result:
|
|
675
|
-
auth_log.info(
|
|
676
|
-
"logout",
|
|
677
|
-
f"Logged out ({scope})",
|
|
678
|
-
scope=scope,
|
|
679
|
-
project_id=project_id,
|
|
680
|
-
)
|
|
681
|
-
return result
|
|
682
|
-
|
|
683
|
-
|
|
684
881
|
@contextmanager
|
|
685
882
|
def swap_credentials_for_loop(
|
|
686
883
|
project_id: Optional[str] = None,
|
|
@@ -711,7 +908,8 @@ def swap_credentials_for_loop(
|
|
|
711
908
|
global _credential_swap_active
|
|
712
909
|
|
|
713
910
|
db = Database()
|
|
714
|
-
|
|
911
|
+
# Get the effective account for this project (assigned or default)
|
|
912
|
+
account = db.get_effective_account(project_id)
|
|
715
913
|
|
|
716
914
|
# Acquire exclusive lock to prevent concurrent credential access
|
|
717
915
|
CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -732,13 +930,13 @@ def swap_credentials_for_loop(
|
|
|
732
930
|
# Mark swap as active AFTER backup is complete
|
|
733
931
|
_credential_swap_active = True
|
|
734
932
|
|
|
735
|
-
# Write credentials
|
|
933
|
+
# Write account credentials to Claude's location
|
|
736
934
|
has_creds = False
|
|
737
|
-
if
|
|
738
|
-
# Default scopes if not stored
|
|
935
|
+
if account:
|
|
936
|
+
# Default scopes if not stored
|
|
739
937
|
# These are the minimum scopes Claude Code CLI needs for execution
|
|
740
938
|
default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
|
|
741
|
-
stored_scopes =
|
|
939
|
+
stored_scopes = account.get("scopes")
|
|
742
940
|
if stored_scopes:
|
|
743
941
|
try:
|
|
744
942
|
scopes = json.loads(stored_scopes)
|
|
@@ -749,20 +947,21 @@ def swap_credentials_for_loop(
|
|
|
749
947
|
|
|
750
948
|
creds_data = {
|
|
751
949
|
"claudeAiOauth": {
|
|
752
|
-
"accessToken":
|
|
753
|
-
"refreshToken":
|
|
754
|
-
"expiresAt":
|
|
950
|
+
"accessToken": account["access_token"],
|
|
951
|
+
"refreshToken": account["refresh_token"],
|
|
952
|
+
"expiresAt": account["expires_at"] * 1000, # Convert to milliseconds
|
|
755
953
|
"scopes": scopes,
|
|
756
954
|
# subscriptionType: Claude subscription tier. Values include "free", "pro", "max".
|
|
757
955
|
# Default to "max" as RalphX is designed for Max subscription users.
|
|
758
|
-
"subscriptionType":
|
|
956
|
+
"subscriptionType": account.get("subscription_type") or "max",
|
|
759
957
|
# rateLimitTier: API rate limit tier. "default_claude_max_20x" indicates
|
|
760
958
|
# the 20x rate limit multiplier for Max subscribers.
|
|
761
|
-
"rateLimitTier":
|
|
959
|
+
"rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
|
|
762
960
|
}
|
|
763
961
|
}
|
|
764
962
|
CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
765
963
|
CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
|
|
964
|
+
os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
|
|
766
965
|
has_creds = True
|
|
767
966
|
|
|
768
967
|
try:
|
|
@@ -778,17 +977,18 @@ def swap_credentials_for_loop(
|
|
|
778
977
|
new_refresh = oauth.get("refreshToken")
|
|
779
978
|
|
|
780
979
|
# If refresh token changed, Claude CLI refreshed during execution
|
|
781
|
-
if new_refresh and new_refresh !=
|
|
782
|
-
db.
|
|
783
|
-
|
|
980
|
+
if new_refresh and new_refresh != account["refresh_token"]:
|
|
981
|
+
db.update_account(
|
|
982
|
+
account["id"],
|
|
784
983
|
access_token=oauth.get("accessToken"),
|
|
785
984
|
refresh_token=new_refresh,
|
|
786
985
|
expires_at=int(oauth.get("expiresAt", 0) / 1000),
|
|
787
986
|
)
|
|
788
987
|
auth_log.info(
|
|
789
988
|
"token_captured",
|
|
790
|
-
f"Captured refreshed token from Claude CLI ({
|
|
791
|
-
|
|
989
|
+
f"Captured refreshed token from Claude CLI ({account['email']})",
|
|
990
|
+
email=account["email"],
|
|
991
|
+
account_id=account["id"],
|
|
792
992
|
project_id=project_id,
|
|
793
993
|
)
|
|
794
994
|
except Exception as e:
|
|
@@ -796,7 +996,7 @@ def swap_credentials_for_loop(
|
|
|
796
996
|
auth_log.warning(
|
|
797
997
|
"token_capture_failed",
|
|
798
998
|
f"Failed to capture refreshed token: {e}",
|
|
799
|
-
|
|
999
|
+
email=account.get("email") if account else None,
|
|
800
1000
|
project_id=project_id,
|
|
801
1001
|
)
|
|
802
1002
|
|