ralphx 0.3.4__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.
- ralphx/__init__.py +1 -1
- ralphx/api/routes/auth.py +703 -94
- 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 +19 -5
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +1 -22
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +346 -171
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +0 -3
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +3 -3
- ralphx/core/planning_service.py +109 -21
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +124 -72
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- 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/workflows.py +114 -32
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-0ovNnfOq.css +1 -0
- ralphx/static/assets/index-CY9s08ZB.js +251 -0
- ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
- ralphx/static/index.html +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/RECORD +35 -35
- 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.3.5.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.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
|
|
1
|
+
"""Authentication routes for Claude accounts."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
4
5
|
import secrets
|
|
5
|
-
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
from
|
|
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
|
-
|
|
73
|
-
|
|
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,
|
|
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
|
-
"""
|
|
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
|
-
|
|
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,33 +322,26 @@ async def validate_credentials(
|
|
|
150
322
|
):
|
|
151
323
|
"""Validate that stored credentials are actually working.
|
|
152
324
|
|
|
153
|
-
|
|
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
|
-
|
|
329
|
+
account = db.get_effective_account(project_id)
|
|
165
330
|
|
|
166
|
-
if not
|
|
331
|
+
if not account:
|
|
167
332
|
return {
|
|
168
333
|
"valid": False,
|
|
169
|
-
"error": "No
|
|
334
|
+
"error": "No account found",
|
|
170
335
|
"scope": None,
|
|
171
336
|
}
|
|
172
337
|
|
|
173
|
-
is_valid, error = await
|
|
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":
|
|
179
|
-
"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
|
|
|
@@ -191,92 +356,536 @@ async def get_usage(
|
|
|
191
356
|
|
|
192
357
|
Returns 5-hour and 7-day utilization percentages.
|
|
193
358
|
"""
|
|
194
|
-
import httpx
|
|
195
|
-
|
|
196
359
|
project_id = _get_project_id(project_path)
|
|
197
360
|
db = Database()
|
|
198
|
-
|
|
361
|
+
account = db.get_effective_account(project_id)
|
|
362
|
+
|
|
363
|
+
if not account or not account.get("access_token"):
|
|
364
|
+
return {
|
|
365
|
+
"success": False,
|
|
366
|
+
"error": "No account found",
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
usage_data = await _fetch_account_usage(account["access_token"])
|
|
199
370
|
|
|
200
|
-
if not
|
|
371
|
+
if not usage_data:
|
|
201
372
|
return {
|
|
202
373
|
"success": False,
|
|
203
|
-
"error": "
|
|
374
|
+
"error": "Failed to fetch usage data",
|
|
204
375
|
}
|
|
205
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"),
|
|
383
|
+
}
|
|
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
|
+
|
|
462
|
+
return {
|
|
463
|
+
"success": True,
|
|
464
|
+
"flow_id": flow_id,
|
|
465
|
+
"message": "Browser opened for authentication",
|
|
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
|
+
|
|
206
567
|
try:
|
|
207
568
|
async with httpx.AsyncClient() as client:
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
},
|
|
210
576
|
headers={
|
|
211
|
-
"
|
|
577
|
+
"Content-Type": "application/json",
|
|
212
578
|
"anthropic-beta": "oauth-2025-04-20",
|
|
213
579
|
},
|
|
214
|
-
timeout=10.0,
|
|
215
580
|
)
|
|
216
581
|
|
|
217
|
-
if
|
|
218
|
-
return {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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)
|
|
222
602
|
|
|
223
|
-
data = response.json()
|
|
224
|
-
five_hour = data.get("five_hour", {})
|
|
225
|
-
seven_day = data.get("seven_day", {})
|
|
226
603
|
return {
|
|
227
604
|
"success": True,
|
|
228
|
-
"
|
|
229
|
-
"
|
|
230
|
-
"seven_day_utilization": seven_day.get("utilization"),
|
|
231
|
-
"seven_day_resets_at": seven_day.get("resets_at"),
|
|
605
|
+
"expires_at": new_expires_at,
|
|
606
|
+
"subscription_type": tokens.get("subscription_type"),
|
|
232
607
|
}
|
|
233
608
|
except Exception as e:
|
|
234
|
-
return {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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")
|
|
238
649
|
|
|
650
|
+
is_valid, error = await validate_account_token(account)
|
|
239
651
|
|
|
240
|
-
|
|
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)
|
|
241
832
|
async def export_credentials(
|
|
242
|
-
scope:
|
|
243
|
-
project_path: Optional[str] = Query(None),
|
|
833
|
+
scope: str = Query("global", description="Scope: 'global' or 'project'"),
|
|
834
|
+
project_path: Optional[str] = Query(None, description="Project path for project scope"),
|
|
244
835
|
):
|
|
245
|
-
"""Export credentials in Claude
|
|
836
|
+
"""Export credentials in Claude CLI format for manual use.
|
|
246
837
|
|
|
247
|
-
Returns credentials in the format
|
|
248
|
-
so users can copy them for use elsewhere.
|
|
838
|
+
Returns credentials in the format expected by ~/.claude/.credentials.json
|
|
249
839
|
"""
|
|
250
|
-
|
|
840
|
+
import json
|
|
841
|
+
|
|
251
842
|
db = Database()
|
|
252
843
|
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
256
872
|
else:
|
|
257
|
-
|
|
873
|
+
scopes = default_scopes
|
|
258
874
|
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
"success": False,
|
|
262
|
-
"error": f"No {scope} credentials found",
|
|
263
|
-
"credentials": None,
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
# Format as Claude Code expects (matching ~/.claude/.credentials.json)
|
|
267
|
-
credentials_json = {
|
|
875
|
+
credentials = {
|
|
268
876
|
"claudeAiOauth": {
|
|
269
|
-
"accessToken":
|
|
270
|
-
"refreshToken":
|
|
271
|
-
"expiresAt":
|
|
272
|
-
"scopes":
|
|
273
|
-
"
|
|
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",
|
|
274
883
|
}
|
|
275
884
|
}
|
|
276
885
|
|
|
277
|
-
return
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
886
|
+
return CredentialsExportResponse(
|
|
887
|
+
success=True,
|
|
888
|
+
scope=scope,
|
|
889
|
+
email=account.get("email"),
|
|
890
|
+
credentials=credentials,
|
|
891
|
+
)
|