ralphx 0.3.5__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/api/routes/auth.py CHANGED
@@ -93,6 +93,53 @@ class AssignAccountRequest(BaseModel):
93
93
  # ============================================================================
94
94
 
95
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
+
96
143
  async def _fetch_account_usage(access_token: str) -> Optional[dict]:
97
144
  """Fetch usage data from Anthropic API."""
98
145
  try:
@@ -112,6 +159,7 @@ async def _fetch_account_usage(access_token: str) -> Optional[dict]:
112
159
  data = response.json()
113
160
  five_hour = data.get("five_hour", {})
114
161
  seven_day = data.get("seven_day", {})
162
+
115
163
  return {
116
164
  "five_hour": five_hour.get("utilization", 0),
117
165
  "seven_day": seven_day.get("utilization", 0),
@@ -184,6 +232,7 @@ from ralphx.core.auth import (
184
232
  AuthStatus,
185
233
  CLIENT_ID,
186
234
  TOKEN_URL,
235
+ _token_refresh_lock,
187
236
  get_auth_status,
188
237
  refresh_token_if_needed,
189
238
  force_refresh_token,
@@ -424,9 +473,20 @@ async def add_account(expected_email: Optional[str] = None):
424
473
 
425
474
  # Use store_oauth_tokens to properly serialize scopes to JSON
426
475
  account = store_oauth_tokens(tokens)
427
-
428
- # Fetch usage data immediately
429
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
430
490
  usage_data = await _fetch_account_usage(tokens["access_token"])
431
491
  if usage_data:
432
492
  db.update_account_usage_cache(
@@ -565,45 +625,58 @@ async def refresh_account_token(account_id: int):
565
625
  return {"success": False, "message": "No refresh token available"}
566
626
 
567
627
  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
- )
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
+ )
581
650
 
582
- if resp.status_code != 200:
583
- return {"success": False, "message": f"Token refresh failed: {resp.status_code}"}
651
+ if resp.status_code != 200:
652
+ return {"success": False, "message": f"Token refresh failed: {resp.status_code}"}
584
653
 
585
- tokens = resp.json()
586
- new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
654
+ tokens = resp.json()
655
+ new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
587
656
 
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
- }
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,
662
+ }
594
663
 
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"]
664
+ db.update_account(account_id, **update_data)
600
665
 
601
- db.update_account(account_id, **update_data)
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"])
602
675
 
603
676
  return {
604
677
  "success": True,
605
678
  "expires_at": new_expires_at,
606
- "subscription_type": tokens.get("subscription_type"),
679
+ "subscription_type": subscription_type,
607
680
  }
608
681
  except Exception as e:
609
682
  return {"success": False, "message": str(e)}