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