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.
Files changed (48) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +10 -2
  3. ralphx/adapters/claude_cli.py +222 -82
  4. ralphx/api/routes/auth.py +780 -98
  5. ralphx/api/routes/config.py +3 -56
  6. ralphx/api/routes/export_import.py +6 -9
  7. ralphx/api/routes/loops.py +4 -4
  8. ralphx/api/routes/planning.py +882 -19
  9. ralphx/api/routes/resources.py +528 -6
  10. ralphx/api/routes/stream.py +58 -56
  11. ralphx/api/routes/templates.py +2 -2
  12. ralphx/api/routes/workflows.py +258 -47
  13. ralphx/cli.py +4 -1
  14. ralphx/core/auth.py +372 -172
  15. ralphx/core/database.py +588 -164
  16. ralphx/core/executor.py +170 -19
  17. ralphx/core/loop.py +15 -2
  18. ralphx/core/loop_templates.py +29 -3
  19. ralphx/core/planning_iteration_executor.py +633 -0
  20. ralphx/core/planning_service.py +119 -24
  21. ralphx/core/preview.py +9 -25
  22. ralphx/core/project_db.py +864 -121
  23. ralphx/core/project_export.py +1 -5
  24. ralphx/core/project_import.py +14 -29
  25. ralphx/core/resources.py +28 -2
  26. ralphx/core/sample_project.py +1 -5
  27. ralphx/core/templates.py +9 -9
  28. ralphx/core/workflow_executor.py +32 -3
  29. ralphx/core/workflow_export.py +4 -7
  30. ralphx/core/workflow_import.py +3 -27
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/tools/diagnostics.py +1 -1
  34. ralphx/mcp/tools/monitoring.py +10 -16
  35. ralphx/mcp/tools/workflows.py +115 -33
  36. ralphx/mcp_server.py +6 -2
  37. ralphx/static/assets/index-BuLI7ffn.css +1 -0
  38. ralphx/static/assets/index-DWvlqOTb.js +264 -0
  39. ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
  40. ralphx/static/index.html +2 -2
  41. ralphx/templates/loop_templates/consumer.md +2 -2
  42. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
  43. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
  44. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  45. ralphx/static/assets/index-CcxfTosc.js +0 -251
  46. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  47. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
  48. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/api/routes/auth.py CHANGED
@@ -1,30 +1,246 @@
1
- """Authentication routes for Claude Code credentials."""
1
+ """Authentication routes for Claude accounts."""
2
2
 
3
3
  import asyncio
4
+ import logging
4
5
  import secrets
5
- from typing import Literal, Optional
6
+ import time
7
+ from typing import Optional
6
8
 
7
- from fastapi import APIRouter, Query
8
- from pydantic import BaseModel
9
+ import httpx
10
+ from fastapi import APIRouter, HTTPException, Query
11
+ from pydantic import BaseModel, Field
9
12
 
10
- from ralphx.core.auth import (
11
- AuthStatus,
12
- clear_credentials,
13
- force_refresh_token,
14
- get_auth_status,
15
- refresh_token_if_needed,
16
- store_oauth_tokens,
17
- validate_token_health,
18
- )
19
13
  from ralphx.core.database import Database
20
14
  from ralphx.core.oauth import OAuthFlow
21
15
 
16
+ logger = logging.getLogger(__name__)
17
+
22
18
  router = APIRouter(prefix="/auth", tags=["auth"])
23
19
 
24
20
  # Track active OAuth flows
25
21
  _active_flows: dict[str, asyncio.Task] = {}
26
22
 
27
23
 
24
+ # ============================================================================
25
+ # Pydantic Models
26
+ # ============================================================================
27
+
28
+
29
+ class AccountUsage(BaseModel):
30
+ """Usage statistics for an account."""
31
+
32
+ five_hour: float = Field(description="5-hour utilization percentage (0-100)")
33
+ seven_day: float = Field(description="7-day utilization percentage (0-100)")
34
+ five_hour_resets_at: Optional[str] = Field(None, description="ISO timestamp when 5h limit resets")
35
+ seven_day_resets_at: Optional[str] = Field(None, description="ISO timestamp when 7d limit resets")
36
+
37
+
38
+ class AccountResponse(BaseModel):
39
+ """Response model for a single account."""
40
+
41
+ id: int
42
+ email: str
43
+ display_name: Optional[str] = None
44
+ subscription_type: Optional[str] = None
45
+ rate_limit_tier: Optional[str] = None
46
+ is_default: bool
47
+ is_active: bool
48
+ expires_at: Optional[int] = None
49
+ expires_in_seconds: Optional[int] = None
50
+ is_expired: bool
51
+ usage: Optional[AccountUsage] = None
52
+ usage_cached_at: Optional[int] = None
53
+ projects_using: int = 0
54
+ last_error: Optional[str] = None
55
+ last_error_at: Optional[str] = None
56
+ consecutive_failures: int = 0
57
+ # Token validation status
58
+ last_validated_at: Optional[int] = None
59
+ validation_status: Optional[str] = None # 'unknown', 'valid', 'invalid', 'checking'
60
+ created_at: Optional[str] = None
61
+
62
+
63
+ class AccountUpdateRequest(BaseModel):
64
+ """Request model for updating an account."""
65
+
66
+ display_name: Optional[str] = None
67
+ is_active: Optional[bool] = None
68
+
69
+
70
+ class ProjectAccountAssignment(BaseModel):
71
+ """Response model for project account assignment."""
72
+
73
+ project_id: str
74
+ account_id: int
75
+ account_email: str
76
+ account_display_name: Optional[str] = None
77
+ subscription_type: Optional[str] = None
78
+ is_active: bool
79
+ is_default: bool
80
+ allow_fallback: bool
81
+ usage: Optional[AccountUsage] = None
82
+
83
+
84
+ class AssignAccountRequest(BaseModel):
85
+ """Request model for assigning account to project."""
86
+
87
+ account_id: int = Field(description="Account ID to assign")
88
+ allow_fallback: bool = Field(default=True, description="Allow fallback to other accounts on rate limit")
89
+
90
+
91
+ # ============================================================================
92
+ # Helper Functions
93
+ # ============================================================================
94
+
95
+
96
+ async def _fetch_account_profile(access_token: str) -> Optional[dict]:
97
+ """Fetch account profile from Anthropic API.
98
+
99
+ Returns subscription type, rate limit tier, and other account info.
100
+ """
101
+ try:
102
+ async with httpx.AsyncClient() as client:
103
+ response = await client.get(
104
+ "https://api.anthropic.com/api/oauth/profile",
105
+ headers={
106
+ "Authorization": f"Bearer {access_token}",
107
+ "Content-Type": "application/json",
108
+ "anthropic-beta": "oauth-2025-04-20",
109
+ },
110
+ timeout=10.0,
111
+ )
112
+
113
+ if response.status_code != 200:
114
+ logger.warning(f"Profile fetch failed: {response.status_code}")
115
+ return None
116
+
117
+ data = response.json()
118
+ org = data.get("organization", {})
119
+ account = data.get("account", {})
120
+
121
+ # Map organization_type to subscription type
122
+ org_type = org.get("organization_type")
123
+ subscription_map = {
124
+ "claude_max": "max",
125
+ "claude_pro": "pro",
126
+ "claude_enterprise": "enterprise",
127
+ "claude_team": "team",
128
+ }
129
+ subscription_type = subscription_map.get(org_type)
130
+
131
+ return {
132
+ "subscription_type": subscription_type,
133
+ "rate_limit_tier": org.get("rate_limit_tier"),
134
+ "has_extra_usage_enabled": org.get("has_extra_usage_enabled"),
135
+ "display_name": account.get("display_name"),
136
+ "full_name": account.get("full_name"),
137
+ }
138
+ except Exception as e:
139
+ logger.warning(f"Profile fetch error: {e}")
140
+ return None
141
+
142
+
143
+ async def _fetch_account_usage(access_token: str) -> Optional[dict]:
144
+ """Fetch usage data from Anthropic API."""
145
+ try:
146
+ async with httpx.AsyncClient() as client:
147
+ response = await client.get(
148
+ "https://api.anthropic.com/api/oauth/usage",
149
+ headers={
150
+ "Authorization": f"Bearer {access_token}",
151
+ "anthropic-beta": "oauth-2025-04-20",
152
+ },
153
+ timeout=10.0,
154
+ )
155
+
156
+ if response.status_code != 200:
157
+ return None
158
+
159
+ data = response.json()
160
+ five_hour = data.get("five_hour", {})
161
+ seven_day = data.get("seven_day", {})
162
+
163
+ return {
164
+ "five_hour": five_hour.get("utilization", 0),
165
+ "seven_day": seven_day.get("utilization", 0),
166
+ "five_hour_resets_at": five_hour.get("resets_at"),
167
+ "seven_day_resets_at": seven_day.get("resets_at"),
168
+ }
169
+ except Exception:
170
+ return None
171
+
172
+
173
+ def _build_account_response(account: dict, db: Database) -> AccountResponse:
174
+ """Build AccountResponse from database account dict."""
175
+ now = int(time.time())
176
+ expires_at = account.get("expires_at", 0)
177
+ is_expired = now >= expires_at if expires_at else True
178
+
179
+ usage = None
180
+ if account.get("cached_usage_5h") is not None or account.get("cached_usage_7d") is not None:
181
+ usage = AccountUsage(
182
+ five_hour=account.get("cached_usage_5h", 0) or 0,
183
+ seven_day=account.get("cached_usage_7d", 0) or 0,
184
+ five_hour_resets_at=account.get("cached_5h_resets_at"),
185
+ seven_day_resets_at=account.get("cached_7d_resets_at"),
186
+ )
187
+
188
+ return AccountResponse(
189
+ id=account["id"],
190
+ email=account["email"],
191
+ display_name=account.get("display_name"),
192
+ subscription_type=account.get("subscription_type"),
193
+ rate_limit_tier=account.get("rate_limit_tier"),
194
+ is_default=bool(account.get("is_default")),
195
+ is_active=bool(account.get("is_active")),
196
+ expires_at=expires_at,
197
+ expires_in_seconds=max(0, expires_at - now) if expires_at else None,
198
+ is_expired=is_expired,
199
+ usage=usage,
200
+ usage_cached_at=account.get("usage_cached_at"),
201
+ projects_using=db.count_projects_using_account(account["id"]),
202
+ last_error=account.get("last_error"),
203
+ last_error_at=account.get("last_error_at"),
204
+ consecutive_failures=account.get("consecutive_failures", 0),
205
+ last_validated_at=account.get("last_validated_at"),
206
+ validation_status=account.get("validation_status", "unknown"),
207
+ created_at=account.get("created_at"),
208
+ )
209
+
210
+
211
+ def _build_usage_from_account(account: dict) -> Optional[AccountUsage]:
212
+ """Build AccountUsage from account dict if usage data exists."""
213
+ if account.get("cached_usage_5h") is None and account.get("cached_usage_7d") is None:
214
+ return None
215
+ return AccountUsage(
216
+ five_hour=account.get("cached_usage_5h", 0) or 0,
217
+ seven_day=account.get("cached_usage_7d", 0) or 0,
218
+ five_hour_resets_at=account.get("cached_5h_resets_at"),
219
+ seven_day_resets_at=account.get("cached_7d_resets_at"),
220
+ )
221
+
222
+
223
+ # ============================================================================
224
+ # Legacy Frontend Endpoints (backwards compatibility with AuthPanel.tsx)
225
+ # These endpoints adapt the new accounts system to the frontend's expectations
226
+ # ============================================================================
227
+
228
+
229
+ from typing import Literal
230
+
231
+ from ralphx.core.auth import (
232
+ AuthStatus,
233
+ CLIENT_ID,
234
+ TOKEN_URL,
235
+ _token_refresh_lock,
236
+ get_auth_status,
237
+ refresh_token_if_needed,
238
+ force_refresh_token,
239
+ store_oauth_tokens,
240
+ validate_account_token,
241
+ )
242
+
243
+
28
244
  class LoginRequest(BaseModel):
29
245
  """Request body for login endpoint."""
30
246
 
@@ -55,7 +271,6 @@ async def get_status(
55
271
 
56
272
  Proactively refreshes tokens when within 5 minutes of expiry,
57
273
  keeping tokens fresh when dashboard is being monitored.
58
- Note: Background task also refreshes tokens within 2 hours of expiry.
59
274
  """
60
275
  project_id = _get_project_id(project_path)
61
276
 
@@ -69,12 +284,8 @@ async def get_status(
69
284
  async def start_login(request: LoginRequest):
70
285
  """Start OAuth flow - opens browser for authentication.
71
286
 
72
- Flow:
73
- 1. Starts localhost callback server
74
- 2. Opens browser to Anthropic OAuth
75
- 3. User authorizes
76
- 4. Callback receives code, exchanges for tokens
77
- 5. Tokens stored in database
287
+ Stores tokens in accounts table. For project scope, assigns the
288
+ new/existing account to the project.
78
289
  """
79
290
  project_id = _get_project_id(request.project_path)
80
291
 
@@ -83,7 +294,7 @@ async def start_login(request: LoginRequest):
83
294
  result = await flow.start()
84
295
  if result.get("success"):
85
296
  tokens = result["tokens"]
86
- store_oauth_tokens(tokens, request.scope, project_id)
297
+ store_oauth_tokens(tokens, project_id)
87
298
  return result
88
299
 
89
300
  flow_id = secrets.token_urlsafe(8)
@@ -124,9 +335,20 @@ async def get_flow_status(flow_id: str):
124
335
 
125
336
  @router.post("/logout")
126
337
  async def logout(request: LoginRequest):
127
- """Clear credentials for the specified scope from database."""
338
+ """Logout: soft-delete the effective account for the scope.
339
+
340
+ Note: In the accounts system, we don't actually delete the account,
341
+ we just mark it as inactive. The frontend will show "not connected".
342
+ """
128
343
  project_id = _get_project_id(request.project_path)
129
- clear_credentials(request.scope, project_id)
344
+ db = Database()
345
+
346
+ # Get effective account
347
+ account = db.get_effective_account(project_id)
348
+ if account:
349
+ # Mark as inactive (soft logout)
350
+ db.update_account(account["id"], is_active=False)
351
+
130
352
  return {"success": True}
131
353
 
132
354
 
@@ -135,7 +357,6 @@ async def refresh_token(request: LoginRequest):
135
357
  """Manually refresh the OAuth token.
136
358
 
137
359
  Forces a token refresh regardless of expiry time.
138
- Useful when users want to ensure they have a fresh token.
139
360
  """
140
361
  project_id = _get_project_id(request.project_path)
141
362
  result = await force_refresh_token(project_id)
@@ -150,33 +371,26 @@ async def validate_credentials(
150
371
  ):
151
372
  """Validate that stored credentials are actually working.
152
373
 
153
- This actually attempts to refresh the OAuth token to verify
154
- the refresh_token is still valid. Unlike /status which just
155
- checks the stored expiry timestamp, this makes a real API call.
156
-
157
- Use this when:
158
- - Troubleshooting authentication issues
159
- - Verifying credentials after they might have been revoked
160
- - Before starting a long-running loop
374
+ Makes a real API call to verify the refresh_token is still valid.
161
375
  """
162
376
  project_id = _get_project_id(project_path)
163
377
  db = Database()
164
- creds = db.get_credentials(project_id)
378
+ account = db.get_effective_account(project_id)
165
379
 
166
- if not creds:
380
+ if not account:
167
381
  return {
168
382
  "valid": False,
169
- "error": "No credentials found",
383
+ "error": "No account found",
170
384
  "scope": None,
171
385
  }
172
386
 
173
- is_valid, error = await validate_token_health(creds)
387
+ is_valid, error = await validate_account_token(account)
174
388
 
175
389
  return {
176
390
  "valid": is_valid,
177
391
  "error": error if not is_valid else None,
178
- "scope": creds.get("scope"),
179
- "email": creds.get("email"),
392
+ "scope": "account",
393
+ "email": account.get("email"),
180
394
  "refreshed": is_valid, # If valid, we also refreshed the token
181
395
  }
182
396
 
@@ -191,92 +405,560 @@ async def get_usage(
191
405
 
192
406
  Returns 5-hour and 7-day utilization percentages.
193
407
  """
194
- import httpx
195
-
196
408
  project_id = _get_project_id(project_path)
197
409
  db = Database()
198
- creds = db.get_credentials(project_id)
410
+ account = db.get_effective_account(project_id)
199
411
 
200
- if not creds or not creds.get("access_token"):
412
+ if not account or not account.get("access_token"):
201
413
  return {
202
414
  "success": False,
203
- "error": "No credentials found",
415
+ "error": "No account found",
204
416
  }
205
417
 
206
- try:
207
- async with httpx.AsyncClient() as client:
208
- response = await client.get(
209
- "https://api.anthropic.com/api/oauth/usage",
210
- headers={
211
- "Authorization": f"Bearer {creds['access_token']}",
212
- "anthropic-beta": "oauth-2025-04-20",
213
- },
214
- timeout=10.0,
418
+ usage_data = await _fetch_account_usage(account["access_token"])
419
+
420
+ if not usage_data:
421
+ return {
422
+ "success": False,
423
+ "error": "Failed to fetch usage data",
424
+ }
425
+
426
+ return {
427
+ "success": True,
428
+ "five_hour_utilization": usage_data["five_hour"],
429
+ "five_hour_resets_at": usage_data.get("five_hour_resets_at"),
430
+ "seven_day_utilization": usage_data["seven_day"],
431
+ "seven_day_resets_at": usage_data.get("seven_day_resets_at"),
432
+ }
433
+
434
+
435
+ # ============================================================================
436
+ # Account Management Endpoints
437
+ # ============================================================================
438
+
439
+
440
+ @router.get("/accounts", response_model=list[AccountResponse])
441
+ async def list_accounts(
442
+ include_inactive: bool = Query(False, description="Include disabled accounts"),
443
+ ):
444
+ """List all connected Claude accounts with usage statistics."""
445
+ db = Database()
446
+ accounts = db.list_accounts(include_inactive=include_inactive)
447
+ return [_build_account_response(acc, db) for acc in accounts]
448
+
449
+
450
+ @router.post("/accounts/add")
451
+ async def add_account(expected_email: Optional[str] = None):
452
+ """Start OAuth flow to add a new account.
453
+
454
+ Opens browser for authentication. On success, creates a new account
455
+ or updates existing if email matches.
456
+
457
+ Args:
458
+ expected_email: If provided (re-auth flow), checks if OAuth email matches.
459
+ On mismatch, still saves the account but flags the mismatch.
460
+ """
461
+
462
+ async def run_flow():
463
+ flow = OAuthFlow()
464
+ result = await flow.start()
465
+ if result.get("success"):
466
+ tokens = result["tokens"]
467
+ email = tokens.get("email")
468
+ if not email:
469
+ return {"success": False, "error": "No email in OAuth response"}
470
+
471
+ # Check for email mismatch (re-auth flow)
472
+ email_mismatch = expected_email and email.lower() != expected_email.lower()
473
+
474
+ # Use store_oauth_tokens to properly serialize scopes to JSON
475
+ account = store_oauth_tokens(tokens)
476
+ db = Database()
477
+
478
+ # Fetch profile to get subscription type
479
+ profile_data = await _fetch_account_profile(tokens["access_token"])
480
+ if profile_data:
481
+ update_fields = {}
482
+ if profile_data.get("subscription_type"):
483
+ update_fields["subscription_type"] = profile_data["subscription_type"]
484
+ if profile_data.get("rate_limit_tier"):
485
+ update_fields["rate_limit_tier"] = profile_data["rate_limit_tier"]
486
+ if update_fields:
487
+ db.update_account(account["id"], **update_fields)
488
+
489
+ # Fetch usage data
490
+ usage_data = await _fetch_account_usage(tokens["access_token"])
491
+ if usage_data:
492
+ db.update_account_usage_cache(
493
+ account["id"],
494
+ five_hour=usage_data["five_hour"],
495
+ seven_day=usage_data["seven_day"],
496
+ five_hour_resets_at=usage_data.get("five_hour_resets_at"),
497
+ seven_day_resets_at=usage_data.get("seven_day_resets_at"),
498
+ )
499
+
500
+ # Return success with mismatch info if applicable
501
+ response = {"success": True, "account_id": account["id"], "email": email}
502
+ if email_mismatch:
503
+ response["email_mismatch"] = True
504
+ response["expected_email"] = expected_email
505
+ response["message"] = (
506
+ f"Signed in as {email} instead of {expected_email}. "
507
+ f"Tokens saved for {email}. "
508
+ f"To fix {expected_email}, sign out of {email} in your browser first, then re-auth."
509
+ )
510
+ return response
511
+ return result
512
+
513
+ flow_id = secrets.token_urlsafe(8)
514
+ task = asyncio.create_task(run_flow())
515
+ _active_flows[flow_id] = task
516
+
517
+ # Clean up completed flows
518
+ for fid in list(_active_flows.keys()):
519
+ if _active_flows[fid].done():
520
+ del _active_flows[fid]
521
+
522
+ return {
523
+ "success": True,
524
+ "flow_id": flow_id,
525
+ "message": "Browser opened for authentication",
526
+ }
527
+
528
+
529
+ @router.get("/accounts/{account_id}", response_model=AccountResponse)
530
+ async def get_account(account_id: int):
531
+ """Get details for a specific account."""
532
+ db = Database()
533
+ account = db.get_account(account_id)
534
+
535
+ if not account:
536
+ raise HTTPException(status_code=404, detail="Account not found")
537
+
538
+ return _build_account_response(account, db)
539
+
540
+
541
+ @router.patch("/accounts/{account_id}", response_model=AccountResponse)
542
+ async def update_account(account_id: int, request: AccountUpdateRequest):
543
+ """Update an account's display name or active status."""
544
+ db = Database()
545
+ account = db.get_account(account_id)
546
+
547
+ if not account:
548
+ raise HTTPException(status_code=404, detail="Account not found")
549
+
550
+ updates = {}
551
+ if request.display_name is not None:
552
+ updates["display_name"] = request.display_name
553
+ if request.is_active is not None:
554
+ updates["is_active"] = request.is_active
555
+
556
+ if updates:
557
+ db.update_account(account_id, **updates)
558
+ account = db.get_account(account_id)
559
+
560
+ return _build_account_response(account, db)
561
+
562
+
563
+ @router.delete("/accounts/{account_id}")
564
+ async def delete_account(account_id: int):
565
+ """Remove an account (soft delete).
566
+
567
+ Cannot delete the default account if other accounts exist.
568
+ Cannot delete an account that is assigned to projects.
569
+ """
570
+ db = Database()
571
+ account = db.get_account(account_id)
572
+
573
+ if not account:
574
+ raise HTTPException(status_code=404, detail="Account not found")
575
+
576
+ # Check if account is assigned to any projects
577
+ projects_using = db.count_projects_using_account(account_id)
578
+ if projects_using > 0:
579
+ raise HTTPException(
580
+ status_code=400,
581
+ detail=f"Cannot delete account: assigned to {projects_using} project(s). Unassign first.",
582
+ )
583
+
584
+ # Check if this is the default and there are other accounts
585
+ if account.get("is_default"):
586
+ other_accounts = [a for a in db.list_accounts() if a["id"] != account_id]
587
+ if other_accounts:
588
+ raise HTTPException(
589
+ status_code=400,
590
+ detail="Cannot delete default account. Set another account as default first.",
215
591
  )
216
592
 
217
- if response.status_code != 200:
218
- return {
219
- "success": False,
220
- "error": f"API error: {response.status_code}",
593
+ db.delete_account(account_id)
594
+ return {"success": True}
595
+
596
+
597
+ @router.post("/accounts/{account_id}/set-default", response_model=AccountResponse)
598
+ async def set_default_account(account_id: int):
599
+ """Set an account as the default."""
600
+ db = Database()
601
+ account = db.get_account(account_id)
602
+
603
+ if not account:
604
+ raise HTTPException(status_code=404, detail="Account not found")
605
+
606
+ if not account.get("is_active"):
607
+ raise HTTPException(status_code=400, detail="Cannot set inactive account as default")
608
+
609
+ db.set_default_account(account_id)
610
+ account = db.get_account(account_id)
611
+
612
+ return _build_account_response(account, db)
613
+
614
+
615
+ @router.post("/accounts/{account_id}/refresh")
616
+ async def refresh_account_token(account_id: int):
617
+ """Refresh an account's OAuth token."""
618
+ db = Database()
619
+ account = db.get_account(account_id)
620
+
621
+ if not account:
622
+ raise HTTPException(status_code=404, detail="Account not found")
623
+
624
+ if not account.get("refresh_token"):
625
+ return {"success": False, "message": "No refresh token available"}
626
+
627
+ try:
628
+ # Use token refresh lock to prevent race conditions with background
629
+ # refresh tasks or concurrent manual refresh requests
630
+ async with _token_refresh_lock():
631
+ # Re-fetch account inside lock to get latest refresh_token
632
+ # (another process may have refreshed while we waited for lock)
633
+ account = db.get_account(account_id)
634
+ if not account or not account.get("refresh_token"):
635
+ return {"success": False, "message": "No refresh token available"}
636
+
637
+ async with httpx.AsyncClient() as client:
638
+ resp = await client.post(
639
+ TOKEN_URL,
640
+ json={
641
+ "grant_type": "refresh_token",
642
+ "refresh_token": account["refresh_token"],
643
+ "client_id": CLIENT_ID,
644
+ },
645
+ headers={
646
+ "Content-Type": "application/json",
647
+ "anthropic-beta": "oauth-2025-04-20",
648
+ },
649
+ )
650
+
651
+ if resp.status_code != 200:
652
+ return {"success": False, "message": f"Token refresh failed: {resp.status_code}"}
653
+
654
+ tokens = resp.json()
655
+ new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
656
+
657
+ # Build update dict with all available fields
658
+ update_data = {
659
+ "access_token": tokens["access_token"],
660
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
661
+ "expires_at": new_expires_at,
221
662
  }
222
663
 
223
- data = response.json()
224
- five_hour = data.get("five_hour", {})
225
- seven_day = data.get("seven_day", {})
664
+ db.update_account(account_id, **update_data)
665
+
666
+ # Fetch profile outside the lock (uses the new access_token, not refresh_token)
667
+ profile_data = await _fetch_account_profile(tokens["access_token"])
668
+ subscription_type = None
669
+ if profile_data:
670
+ if profile_data.get("subscription_type"):
671
+ subscription_type = profile_data["subscription_type"]
672
+ db.update_account(account_id, subscription_type=subscription_type)
673
+ if profile_data.get("rate_limit_tier"):
674
+ db.update_account(account_id, rate_limit_tier=profile_data["rate_limit_tier"])
675
+
226
676
  return {
227
677
  "success": True,
228
- "five_hour_utilization": five_hour.get("utilization"),
229
- "five_hour_resets_at": five_hour.get("resets_at"),
230
- "seven_day_utilization": seven_day.get("utilization"),
231
- "seven_day_resets_at": seven_day.get("resets_at"),
678
+ "expires_at": new_expires_at,
679
+ "subscription_type": subscription_type,
232
680
  }
233
681
  except Exception as e:
234
- return {
235
- "success": False,
236
- "error": str(e),
237
- }
682
+ return {"success": False, "message": str(e)}
683
+
684
+
685
+ @router.post("/accounts/{account_id}/refresh-usage")
686
+ async def refresh_account_usage(account_id: int):
687
+ """Refresh usage statistics for an account."""
688
+ db = Database()
689
+ account = db.get_account(account_id)
690
+
691
+ if not account:
692
+ raise HTTPException(status_code=404, detail="Account not found")
693
+
694
+ usage_data = await _fetch_account_usage(account["access_token"])
695
+
696
+ if not usage_data:
697
+ return {"success": False, "error": "Failed to fetch usage data"}
698
+
699
+ db.update_account_usage_cache(
700
+ account_id,
701
+ five_hour=usage_data["five_hour"],
702
+ seven_day=usage_data["seven_day"],
703
+ five_hour_resets_at=usage_data.get("five_hour_resets_at"),
704
+ seven_day_resets_at=usage_data.get("seven_day_resets_at"),
705
+ )
706
+
707
+ return {"success": True, "usage": usage_data}
708
+
238
709
 
710
+ @router.post("/accounts/{account_id}/validate")
711
+ async def validate_account(account_id: int):
712
+ """Validate that an account's token is actually working.
239
713
 
240
- @router.get("/credentials/export")
714
+ Makes a real API call to verify the refresh_token is still valid.
715
+ Returns validation result without blocking.
716
+ """
717
+ db = Database()
718
+ account = db.get_account(account_id)
719
+
720
+ if not account:
721
+ raise HTTPException(status_code=404, detail="Account not found")
722
+
723
+ is_valid, error = await validate_account_token(account)
724
+
725
+ # Update account with validation status
726
+ now = int(time.time())
727
+ now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
728
+ update_kwargs = {
729
+ "last_validated_at": now,
730
+ "validation_status": 'valid' if is_valid else 'invalid',
731
+ }
732
+ if is_valid:
733
+ update_kwargs["last_error"] = None
734
+ update_kwargs["last_error_at"] = None
735
+ else:
736
+ update_kwargs["last_error"] = error
737
+ update_kwargs["last_error_at"] = now_iso
738
+
739
+ try:
740
+ updated = db.update_account(account_id, **update_kwargs)
741
+ if not updated:
742
+ # Account was deleted between get and update - rare race condition
743
+ raise HTTPException(status_code=404, detail="Account was deleted during validation")
744
+ except HTTPException:
745
+ raise
746
+ except Exception as e:
747
+ # Log but don't fail - validation result is still valid
748
+ # The validation already happened (and token may have been refreshed)
749
+ logger.warning(
750
+ f"Failed to update validation status for account {account_id}: {e}"
751
+ )
752
+
753
+ return {
754
+ "valid": is_valid,
755
+ "error": error if not is_valid else None,
756
+ "email": account.get("email"),
757
+ }
758
+
759
+
760
+ @router.post("/accounts/refresh-all-usage")
761
+ async def refresh_all_accounts_usage():
762
+ """Refresh usage statistics for all active accounts."""
763
+ db = Database()
764
+ accounts = db.list_accounts(include_inactive=False)
765
+
766
+ results = {"refreshed": 0, "failed": 0, "accounts": []}
767
+
768
+ for account in accounts:
769
+ usage_data = await _fetch_account_usage(account["access_token"])
770
+
771
+ if usage_data:
772
+ db.update_account_usage_cache(
773
+ account["id"],
774
+ five_hour=usage_data["five_hour"],
775
+ seven_day=usage_data["seven_day"],
776
+ five_hour_resets_at=usage_data.get("five_hour_resets_at"),
777
+ seven_day_resets_at=usage_data.get("seven_day_resets_at"),
778
+ )
779
+ results["refreshed"] += 1
780
+ results["accounts"].append({"id": account["id"], "email": account["email"], "success": True})
781
+ else:
782
+ results["failed"] += 1
783
+ results["accounts"].append({"id": account["id"], "email": account["email"], "success": False})
784
+
785
+ return results
786
+
787
+
788
+ # ============================================================================
789
+ # Project Account Assignment Endpoints
790
+ # ============================================================================
791
+
792
+
793
+ @router.get("/projects/{project_id}/account", response_model=Optional[ProjectAccountAssignment])
794
+ async def get_project_account(project_id: str):
795
+ """Get the account assigned to a project (if any)."""
796
+ db = Database()
797
+
798
+ # Verify project exists
799
+ project = db.get_project_by_id(project_id)
800
+ if not project:
801
+ raise HTTPException(status_code=404, detail="Project not found")
802
+
803
+ assignment = db.get_project_account_assignment(project_id)
804
+ if not assignment:
805
+ return None
806
+
807
+ account = db.get_account(assignment["account_id"])
808
+ if not account:
809
+ return None
810
+
811
+ return ProjectAccountAssignment(
812
+ project_id=project_id,
813
+ account_id=account["id"],
814
+ account_email=account["email"],
815
+ account_display_name=account.get("display_name"),
816
+ subscription_type=account.get("subscription_type"),
817
+ is_active=bool(account.get("is_active")),
818
+ is_default=bool(account.get("is_default")),
819
+ allow_fallback=bool(assignment.get("allow_fallback", True)),
820
+ usage=_build_usage_from_account(account),
821
+ )
822
+
823
+
824
+ @router.post("/projects/{project_id}/account", response_model=ProjectAccountAssignment)
825
+ async def assign_project_account(project_id: str, request: AssignAccountRequest):
826
+ """Assign an account to a project."""
827
+ db = Database()
828
+
829
+ # Verify project exists
830
+ project = db.get_project_by_id(project_id)
831
+ if not project:
832
+ raise HTTPException(status_code=404, detail="Project not found")
833
+
834
+ # Verify account exists and is active
835
+ account = db.get_account(request.account_id)
836
+ if not account:
837
+ raise HTTPException(status_code=404, detail="Account not found")
838
+
839
+ if not account.get("is_active"):
840
+ raise HTTPException(status_code=400, detail="Cannot assign inactive account")
841
+
842
+ # Create or update assignment
843
+ db.assign_account_to_project(project_id, request.account_id, request.allow_fallback)
844
+
845
+ return ProjectAccountAssignment(
846
+ project_id=project_id,
847
+ account_id=account["id"],
848
+ account_email=account["email"],
849
+ account_display_name=account.get("display_name"),
850
+ subscription_type=account.get("subscription_type"),
851
+ is_active=bool(account.get("is_active")),
852
+ is_default=bool(account.get("is_default")),
853
+ allow_fallback=request.allow_fallback,
854
+ usage=_build_usage_from_account(account),
855
+ )
856
+
857
+
858
+ @router.delete("/projects/{project_id}/account")
859
+ async def unassign_project_account(project_id: str):
860
+ """Remove account assignment from a project (will use default)."""
861
+ db = Database()
862
+
863
+ # Verify project exists
864
+ project = db.get_project_by_id(project_id)
865
+ if not project:
866
+ raise HTTPException(status_code=404, detail="Project not found")
867
+
868
+ db.unassign_account_from_project(project_id)
869
+ return {"success": True}
870
+
871
+
872
+ @router.get("/projects/{project_id}/effective-account", response_model=Optional[AccountResponse])
873
+ async def get_effective_project_account(project_id: str):
874
+ """Get the effective account for a project (assigned or default)."""
875
+ db = Database()
876
+
877
+ # Verify project exists
878
+ project = db.get_project_by_id(project_id)
879
+ if not project:
880
+ raise HTTPException(status_code=404, detail="Project not found")
881
+
882
+ account = db.get_effective_account(project_id)
883
+ if not account:
884
+ return None
885
+
886
+ return _build_account_response(account, db)
887
+
888
+
889
+ # ============================================================================
890
+ # Credentials Export Endpoint (for CLI usage)
891
+ # ============================================================================
892
+
893
+
894
+ class CredentialsExportResponse(BaseModel):
895
+ """Response for credentials export."""
896
+
897
+ success: bool
898
+ error: Optional[str] = None
899
+ scope: Optional[str] = None
900
+ email: Optional[str] = None
901
+ credentials: Optional[dict] = None
902
+
903
+
904
+ @router.get("/credentials/export", response_model=CredentialsExportResponse)
241
905
  async def export_credentials(
242
- scope: Literal["project", "global"] = Query("global"),
243
- project_path: Optional[str] = Query(None),
906
+ scope: str = Query("global", description="Scope: 'global' or 'project'"),
907
+ project_path: Optional[str] = Query(None, description="Project path for project scope"),
244
908
  ):
245
- """Export credentials in Claude Code JSON format.
909
+ """Export credentials in Claude CLI format for manual use.
246
910
 
247
- Returns credentials in the format used by ~/.claude/.credentials.json
248
- so users can copy them for use elsewhere.
911
+ Returns credentials in the format expected by ~/.claude/.credentials.json
249
912
  """
250
- project_id = _get_project_id(project_path)
913
+ import json
914
+
251
915
  db = Database()
252
916
 
253
- # Get credentials for the specified scope
254
- if scope == "project" and project_id:
255
- creds = db.get_credentials_by_scope("project", project_id)
917
+ # Determine project_id if project scope
918
+ project_id = None
919
+ if scope == "project" and project_path:
920
+ # Find project by path
921
+ projects = db.list_projects()
922
+ for p in projects:
923
+ if p.get("path") == project_path:
924
+ project_id = p.get("id")
925
+ break
926
+
927
+ # Get effective account
928
+ account = db.get_effective_account(project_id)
929
+
930
+ if not account:
931
+ return CredentialsExportResponse(
932
+ success=False,
933
+ error="No account found. Please login first.",
934
+ scope=scope,
935
+ )
936
+
937
+ # Build credentials in Claude CLI format
938
+ default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
939
+ stored_scopes = account.get("scopes")
940
+ if stored_scopes:
941
+ try:
942
+ scopes = json.loads(stored_scopes) if isinstance(stored_scopes, str) else stored_scopes
943
+ except (json.JSONDecodeError, TypeError):
944
+ scopes = default_scopes
256
945
  else:
257
- creds = db.get_credentials_by_scope("global", None)
258
-
259
- if not creds:
260
- return {
261
- "success": False,
262
- "error": f"No {scope} credentials found",
263
- "credentials": None,
264
- }
946
+ scopes = default_scopes
265
947
 
266
- # Format as Claude Code expects (matching ~/.claude/.credentials.json)
267
- credentials_json = {
948
+ credentials = {
268
949
  "claudeAiOauth": {
269
- "accessToken": creds["access_token"],
270
- "refreshToken": creds["refresh_token"],
271
- "expiresAt": creds["expires_at"] * 1000, # Convert to milliseconds
272
- "scopes": ["user:inference", "user:profile", "user:sessions:claude_code"],
273
- "email": creds.get("email"),
950
+ "accessToken": account["access_token"],
951
+ "refreshToken": account.get("refresh_token"),
952
+ "expiresAt": account.get("expires_at", 0) * 1000, # Convert to milliseconds
953
+ "scopes": scopes,
954
+ "subscriptionType": account.get("subscription_type") or "max",
955
+ "rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
274
956
  }
275
957
  }
276
958
 
277
- return {
278
- "success": True,
279
- "scope": creds["scope"],
280
- "email": creds.get("email"),
281
- "credentials": credentials_json,
282
- }
959
+ return CredentialsExportResponse(
960
+ success=True,
961
+ scope=scope,
962
+ email=account.get("email"),
963
+ credentials=credentials,
964
+ )