ralphx 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/api/routes/auth.py
CHANGED
|
@@ -1,30 +1,246 @@
|
|
|
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_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
|
-
|
|
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
|
|
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,
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
+
account = db.get_effective_account(project_id)
|
|
165
379
|
|
|
166
|
-
if not
|
|
380
|
+
if not account:
|
|
167
381
|
return {
|
|
168
382
|
"valid": False,
|
|
169
|
-
"error": "No
|
|
383
|
+
"error": "No account found",
|
|
170
384
|
"scope": None,
|
|
171
385
|
}
|
|
172
386
|
|
|
173
|
-
is_valid, error = await
|
|
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":
|
|
179
|
-
"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
|
-
|
|
410
|
+
account = db.get_effective_account(project_id)
|
|
199
411
|
|
|
200
|
-
if not
|
|
412
|
+
if not account or not account.get("access_token"):
|
|
201
413
|
return {
|
|
202
414
|
"success": False,
|
|
203
|
-
"error": "No
|
|
415
|
+
"error": "No account found",
|
|
204
416
|
}
|
|
205
417
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
"
|
|
229
|
-
"
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
909
|
+
"""Export credentials in Claude CLI format for manual use.
|
|
246
910
|
|
|
247
|
-
Returns credentials in the format
|
|
248
|
-
so users can copy them for use elsewhere.
|
|
911
|
+
Returns credentials in the format expected by ~/.claude/.credentials.json
|
|
249
912
|
"""
|
|
250
|
-
|
|
913
|
+
import json
|
|
914
|
+
|
|
251
915
|
db = Database()
|
|
252
916
|
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
credentials_json = {
|
|
948
|
+
credentials = {
|
|
268
949
|
"claudeAiOauth": {
|
|
269
|
-
"accessToken":
|
|
270
|
-
"refreshToken":
|
|
271
|
-
"expiresAt":
|
|
272
|
-
"scopes":
|
|
273
|
-
"
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
959
|
+
return CredentialsExportResponse(
|
|
960
|
+
success=True,
|
|
961
|
+
scope=scope,
|
|
962
|
+
email=account.get("email"),
|
|
963
|
+
credentials=credentials,
|
|
964
|
+
)
|