repr-cli 0.1.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.
- repr/__init__.py +10 -0
- repr/analyzer.py +915 -0
- repr/api.py +263 -0
- repr/auth.py +300 -0
- repr/cli.py +858 -0
- repr/config.py +392 -0
- repr/discovery.py +472 -0
- repr/extractor.py +388 -0
- repr/highlights.py +712 -0
- repr/openai_analysis.py +597 -0
- repr/tools.py +446 -0
- repr/ui.py +430 -0
- repr_cli-0.1.0.dist-info/METADATA +326 -0
- repr_cli-0.1.0.dist-info/RECORD +18 -0
- repr_cli-0.1.0.dist-info/WHEEL +5 -0
- repr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repr_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- repr_cli-0.1.0.dist-info/top_level.txt +1 -0
repr/api.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST API client for repr.dev endpoints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .auth import require_auth, AuthError
|
|
11
|
+
from .config import get_api_base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_profile_url() -> str:
|
|
15
|
+
return f"{get_api_base()}/profile"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_repo_profile_url() -> str:
|
|
19
|
+
return f"{get_api_base()}/repo-profile"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_user_url() -> str:
|
|
23
|
+
return f"{get_api_base()}/user"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class APIError(Exception):
|
|
27
|
+
"""API request error."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_headers() -> dict[str, str]:
|
|
32
|
+
"""Get headers with authentication."""
|
|
33
|
+
token = require_auth()
|
|
34
|
+
return {
|
|
35
|
+
"Authorization": f"Bearer {token}",
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"User-Agent": "repr-cli/0.1.0",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compute_content_hash(content: str) -> str:
|
|
42
|
+
"""Compute SHA256 hash of content."""
|
|
43
|
+
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def push_profile(content: str, profile_name: str, analyzed_repos: list[dict[str, Any] | str] | None = None) -> dict[str, Any]:
|
|
47
|
+
"""
|
|
48
|
+
Push a profile to repr.dev.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
content: Markdown content of the profile
|
|
52
|
+
profile_name: Name/identifier of the profile
|
|
53
|
+
analyzed_repos: Optional list of repository metadata (dicts) or names (strings for backward compat)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Response data with profile URL
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
APIError: If upload fails
|
|
60
|
+
AuthError: If not authenticated
|
|
61
|
+
"""
|
|
62
|
+
async with httpx.AsyncClient() as client:
|
|
63
|
+
try:
|
|
64
|
+
# Compute content hash
|
|
65
|
+
content_hash = compute_content_hash(content)
|
|
66
|
+
|
|
67
|
+
payload = {
|
|
68
|
+
"content": content,
|
|
69
|
+
"name": profile_name,
|
|
70
|
+
"content_hash": content_hash,
|
|
71
|
+
}
|
|
72
|
+
if analyzed_repos is not None:
|
|
73
|
+
payload["analyzed_repos"] = analyzed_repos
|
|
74
|
+
|
|
75
|
+
response = await client.post(
|
|
76
|
+
_get_profile_url(),
|
|
77
|
+
headers=_get_headers(),
|
|
78
|
+
json=payload,
|
|
79
|
+
timeout=60,
|
|
80
|
+
)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
return response.json()
|
|
83
|
+
|
|
84
|
+
except httpx.HTTPStatusError as e:
|
|
85
|
+
if e.response.status_code == 401:
|
|
86
|
+
raise AuthError("Session expired. Please run 'repr login' again.")
|
|
87
|
+
elif e.response.status_code == 413:
|
|
88
|
+
raise APIError("Profile too large to upload.")
|
|
89
|
+
else:
|
|
90
|
+
raise APIError(f"Upload failed: {e.response.status_code}")
|
|
91
|
+
except httpx.RequestError as e:
|
|
92
|
+
raise APIError(f"Network error: {str(e)}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def get_user_profile() -> dict[str, Any] | None:
|
|
96
|
+
"""
|
|
97
|
+
Get the user's current profile from the server.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Profile data or None if not found
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
APIError: If request fails
|
|
104
|
+
AuthError: If not authenticated
|
|
105
|
+
"""
|
|
106
|
+
async with httpx.AsyncClient() as client:
|
|
107
|
+
try:
|
|
108
|
+
response = await client.get(
|
|
109
|
+
_get_profile_url(),
|
|
110
|
+
headers=_get_headers(),
|
|
111
|
+
timeout=30,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if response.status_code == 404:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
return response.json()
|
|
119
|
+
|
|
120
|
+
except httpx.HTTPStatusError as e:
|
|
121
|
+
if e.response.status_code == 401:
|
|
122
|
+
raise AuthError("Session expired. Please run 'repr login' again.")
|
|
123
|
+
raise APIError(f"Failed to get profile: {e.response.status_code}")
|
|
124
|
+
except httpx.RequestError as e:
|
|
125
|
+
raise APIError(f"Network error: {str(e)}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def get_user_info() -> dict[str, Any]:
|
|
129
|
+
"""
|
|
130
|
+
Get current user information.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
User info dict
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
APIError: If request fails
|
|
137
|
+
AuthError: If not authenticated
|
|
138
|
+
"""
|
|
139
|
+
async with httpx.AsyncClient() as client:
|
|
140
|
+
try:
|
|
141
|
+
response = await client.get(
|
|
142
|
+
_get_user_url(),
|
|
143
|
+
headers=_get_headers(),
|
|
144
|
+
timeout=30,
|
|
145
|
+
)
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
except httpx.HTTPStatusError as e:
|
|
150
|
+
if e.response.status_code == 401:
|
|
151
|
+
raise AuthError("Session expired. Please run 'repr login' again.")
|
|
152
|
+
raise APIError(f"Failed to get user info: {e.response.status_code}")
|
|
153
|
+
except httpx.RequestError as e:
|
|
154
|
+
raise APIError(f"Network error: {str(e)}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def delete_profile() -> bool:
|
|
158
|
+
"""
|
|
159
|
+
Delete the user's profile from the server.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if deleted successfully
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
APIError: If request fails
|
|
166
|
+
AuthError: If not authenticated
|
|
167
|
+
"""
|
|
168
|
+
async with httpx.AsyncClient() as client:
|
|
169
|
+
try:
|
|
170
|
+
response = await client.delete(
|
|
171
|
+
_get_profile_url(),
|
|
172
|
+
headers=_get_headers(),
|
|
173
|
+
timeout=30,
|
|
174
|
+
)
|
|
175
|
+
response.raise_for_status()
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
except httpx.HTTPStatusError as e:
|
|
179
|
+
if e.response.status_code == 401:
|
|
180
|
+
raise AuthError("Session expired. Please run 'repr login' again.")
|
|
181
|
+
elif e.response.status_code == 404:
|
|
182
|
+
return True # Already deleted
|
|
183
|
+
raise APIError(f"Failed to delete profile: {e.response.status_code}")
|
|
184
|
+
except httpx.RequestError as e:
|
|
185
|
+
raise APIError(f"Network error: {str(e)}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def push_repo_profile(
|
|
189
|
+
content: str,
|
|
190
|
+
repo_name: str,
|
|
191
|
+
repo_metadata: dict[str, Any],
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""
|
|
194
|
+
Push a single repository profile to repr.dev.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
content: Markdown content of the profile
|
|
198
|
+
repo_name: Name of the repository
|
|
199
|
+
repo_metadata: Repository metadata (commit_count, languages, etc.)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Response data with profile URL
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
APIError: If upload fails
|
|
206
|
+
AuthError: If not authenticated
|
|
207
|
+
"""
|
|
208
|
+
async with httpx.AsyncClient() as client:
|
|
209
|
+
try:
|
|
210
|
+
content_hash = compute_content_hash(content)
|
|
211
|
+
|
|
212
|
+
payload = {
|
|
213
|
+
"repo_name": repo_name,
|
|
214
|
+
"content": content,
|
|
215
|
+
"content_hash": content_hash,
|
|
216
|
+
**repo_metadata,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
response = await client.post(
|
|
220
|
+
_get_repo_profile_url(),
|
|
221
|
+
headers=_get_headers(),
|
|
222
|
+
json=payload,
|
|
223
|
+
timeout=60,
|
|
224
|
+
)
|
|
225
|
+
response.raise_for_status()
|
|
226
|
+
return response.json()
|
|
227
|
+
|
|
228
|
+
except httpx.HTTPStatusError as e:
|
|
229
|
+
if e.response.status_code == 401:
|
|
230
|
+
raise AuthError("Session expired. Please run 'repr login' again.")
|
|
231
|
+
elif e.response.status_code == 413:
|
|
232
|
+
raise APIError("Profile too large to upload.")
|
|
233
|
+
else:
|
|
234
|
+
raise APIError(f"Upload failed: {e.response.status_code}")
|
|
235
|
+
except httpx.RequestError as e:
|
|
236
|
+
raise APIError(f"Network error: {str(e)}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def sync_push_profile(content: str, profile_name: str, analyzed_repos: list[str] | None = None) -> dict[str, Any]:
|
|
240
|
+
"""
|
|
241
|
+
Synchronous wrapper for push_profile.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
content: Markdown content of the profile
|
|
245
|
+
profile_name: Name/identifier of the profile
|
|
246
|
+
analyzed_repos: Optional list of repository names analyzed
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Response data with profile URL
|
|
250
|
+
"""
|
|
251
|
+
import asyncio
|
|
252
|
+
return asyncio.run(push_profile(content, profile_name, analyzed_repos))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def sync_get_user_info() -> dict[str, Any]:
|
|
256
|
+
"""
|
|
257
|
+
Synchronous wrapper for get_user_info.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
User info dict
|
|
261
|
+
"""
|
|
262
|
+
import asyncio
|
|
263
|
+
return asyncio.run(get_user_info())
|
repr/auth.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication via device code flow.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .config import set_auth, clear_auth, get_auth, is_authenticated, get_api_base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_device_code_url() -> str:
|
|
15
|
+
return f"{get_api_base()}/device-code"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_token_url() -> str:
|
|
19
|
+
return f"{get_api_base()}/token"
|
|
20
|
+
|
|
21
|
+
# Polling configuration
|
|
22
|
+
POLL_INTERVAL = 5 # seconds
|
|
23
|
+
MAX_POLL_TIME = 600 # 10 minutes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DeviceCodeResponse:
|
|
28
|
+
"""Response from device code request."""
|
|
29
|
+
device_code: str
|
|
30
|
+
user_code: str
|
|
31
|
+
verification_url: str
|
|
32
|
+
expires_in: int
|
|
33
|
+
interval: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TokenResponse:
|
|
38
|
+
"""Response from token request."""
|
|
39
|
+
access_token: str
|
|
40
|
+
user_id: str
|
|
41
|
+
email: str
|
|
42
|
+
litellm_api_key: str | None = None # rf_* token for LLM proxy authentication
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthError(Exception):
|
|
46
|
+
"""Authentication error."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def request_device_code() -> DeviceCodeResponse:
|
|
51
|
+
"""
|
|
52
|
+
Request a new device code for authentication.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
DeviceCodeResponse with user code to display
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
AuthError: If request fails
|
|
59
|
+
"""
|
|
60
|
+
async with httpx.AsyncClient() as client:
|
|
61
|
+
try:
|
|
62
|
+
response = await client.post(
|
|
63
|
+
_get_device_code_url(),
|
|
64
|
+
json={"client_id": "repr-cli"},
|
|
65
|
+
timeout=30,
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
data = response.json()
|
|
69
|
+
|
|
70
|
+
return DeviceCodeResponse(
|
|
71
|
+
device_code=data["device_code"],
|
|
72
|
+
user_code=data["user_code"],
|
|
73
|
+
verification_url=data.get("verification_url", "https://repr.dev/device"),
|
|
74
|
+
expires_in=data.get("expires_in", 600),
|
|
75
|
+
interval=data.get("interval", 5),
|
|
76
|
+
)
|
|
77
|
+
except httpx.HTTPStatusError as e:
|
|
78
|
+
raise AuthError(f"Failed to get device code: {e.response.status_code}")
|
|
79
|
+
except httpx.RequestError as e:
|
|
80
|
+
raise AuthError(f"Network error: {str(e)}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def poll_for_token(device_code: str, interval: int = POLL_INTERVAL) -> TokenResponse:
|
|
84
|
+
"""
|
|
85
|
+
Poll for access token after user authorizes.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
device_code: The device code from initial request
|
|
89
|
+
interval: Polling interval in seconds
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
TokenResponse with access token
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
AuthError: If polling fails or times out
|
|
96
|
+
"""
|
|
97
|
+
start_time = time.time()
|
|
98
|
+
|
|
99
|
+
async with httpx.AsyncClient() as client:
|
|
100
|
+
while time.time() - start_time < MAX_POLL_TIME:
|
|
101
|
+
try:
|
|
102
|
+
response = await client.post(
|
|
103
|
+
_get_token_url(),
|
|
104
|
+
json={
|
|
105
|
+
"device_code": device_code,
|
|
106
|
+
"client_id": "repr-cli",
|
|
107
|
+
},
|
|
108
|
+
timeout=30,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if response.status_code == 200:
|
|
112
|
+
data = response.json()
|
|
113
|
+
return TokenResponse(
|
|
114
|
+
access_token=data["access_token"],
|
|
115
|
+
user_id=data["user_id"],
|
|
116
|
+
email=data["email"],
|
|
117
|
+
litellm_api_key=data.get("litellm_api_key"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if response.status_code == 400:
|
|
121
|
+
data = response.json()
|
|
122
|
+
error = data.get("error", "unknown")
|
|
123
|
+
|
|
124
|
+
if error == "authorization_pending":
|
|
125
|
+
# User hasn't authorized yet, continue polling
|
|
126
|
+
await asyncio.sleep(interval)
|
|
127
|
+
continue
|
|
128
|
+
elif error == "slow_down":
|
|
129
|
+
# Increase interval
|
|
130
|
+
interval = min(interval + 5, 30)
|
|
131
|
+
await asyncio.sleep(interval)
|
|
132
|
+
continue
|
|
133
|
+
elif error == "expired_token":
|
|
134
|
+
raise AuthError("Device code expired. Please try again.")
|
|
135
|
+
elif error == "access_denied":
|
|
136
|
+
raise AuthError("Authorization denied by user.")
|
|
137
|
+
else:
|
|
138
|
+
raise AuthError(f"Authorization failed: {error}")
|
|
139
|
+
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
|
|
142
|
+
except httpx.RequestError as e:
|
|
143
|
+
# Network error, retry
|
|
144
|
+
await asyncio.sleep(interval)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
raise AuthError("Authorization timed out. Please try again.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def save_token(token_response: TokenResponse) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Save authentication token to config.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
token_response: Token response from successful auth
|
|
156
|
+
"""
|
|
157
|
+
set_auth(
|
|
158
|
+
access_token=token_response.access_token,
|
|
159
|
+
user_id=token_response.user_id,
|
|
160
|
+
email=token_response.email,
|
|
161
|
+
litellm_api_key=token_response.litellm_api_key,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def logout() -> None:
|
|
166
|
+
"""Clear authentication and logout."""
|
|
167
|
+
clear_auth()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_current_user() -> dict | None:
|
|
171
|
+
"""
|
|
172
|
+
Get current authenticated user info.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
User info dict or None if not authenticated
|
|
176
|
+
"""
|
|
177
|
+
return get_auth()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def require_auth() -> str:
|
|
181
|
+
"""
|
|
182
|
+
Get access token, raising error if not authenticated.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Access token
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
AuthError: If not authenticated
|
|
189
|
+
"""
|
|
190
|
+
auth = get_auth()
|
|
191
|
+
if not auth or not auth.get("access_token"):
|
|
192
|
+
raise AuthError("Not authenticated. Run 'repr login' first.")
|
|
193
|
+
return auth["access_token"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AuthFlow:
|
|
197
|
+
"""
|
|
198
|
+
Manages the device code authentication flow with progress callbacks.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
on_code_received: callable = None,
|
|
204
|
+
on_progress: callable = None,
|
|
205
|
+
on_success: callable = None,
|
|
206
|
+
on_error: callable = None,
|
|
207
|
+
):
|
|
208
|
+
self.on_code_received = on_code_received
|
|
209
|
+
self.on_progress = on_progress
|
|
210
|
+
self.on_success = on_success
|
|
211
|
+
self.on_error = on_error
|
|
212
|
+
self._cancelled = False
|
|
213
|
+
|
|
214
|
+
def cancel(self) -> None:
|
|
215
|
+
"""Cancel the authentication flow."""
|
|
216
|
+
self._cancelled = True
|
|
217
|
+
|
|
218
|
+
async def run(self) -> TokenResponse | None:
|
|
219
|
+
"""
|
|
220
|
+
Run the full authentication flow.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
TokenResponse if successful, None if cancelled
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
# Request device code
|
|
227
|
+
device_code_response = await request_device_code()
|
|
228
|
+
|
|
229
|
+
if self.on_code_received:
|
|
230
|
+
self.on_code_received(device_code_response)
|
|
231
|
+
|
|
232
|
+
# Poll for token
|
|
233
|
+
start_time = time.time()
|
|
234
|
+
interval = device_code_response.interval
|
|
235
|
+
|
|
236
|
+
async with httpx.AsyncClient() as client:
|
|
237
|
+
while not self._cancelled:
|
|
238
|
+
elapsed = time.time() - start_time
|
|
239
|
+
remaining = device_code_response.expires_in - elapsed
|
|
240
|
+
|
|
241
|
+
if remaining <= 0:
|
|
242
|
+
raise AuthError("Device code expired. Please try again.")
|
|
243
|
+
|
|
244
|
+
if self.on_progress:
|
|
245
|
+
self.on_progress(remaining)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
response = await client.post(
|
|
249
|
+
_get_token_url(),
|
|
250
|
+
json={
|
|
251
|
+
"device_code": device_code_response.device_code,
|
|
252
|
+
"client_id": "repr-cli",
|
|
253
|
+
},
|
|
254
|
+
timeout=30,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if response.status_code == 200:
|
|
258
|
+
data = response.json()
|
|
259
|
+
user_data = data.get("user", {})
|
|
260
|
+
token = TokenResponse(
|
|
261
|
+
access_token=data["access_token"],
|
|
262
|
+
user_id=user_data.get("id", ""),
|
|
263
|
+
email=user_data.get("email", ""),
|
|
264
|
+
litellm_api_key=data.get("litellm_api_key"),
|
|
265
|
+
)
|
|
266
|
+
save_token(token)
|
|
267
|
+
|
|
268
|
+
if self.on_success:
|
|
269
|
+
self.on_success(token)
|
|
270
|
+
|
|
271
|
+
return token
|
|
272
|
+
|
|
273
|
+
if response.status_code == 400:
|
|
274
|
+
data = response.json()
|
|
275
|
+
error = data.get("error", "unknown")
|
|
276
|
+
|
|
277
|
+
if error == "authorization_pending":
|
|
278
|
+
await asyncio.sleep(interval)
|
|
279
|
+
continue
|
|
280
|
+
elif error == "slow_down":
|
|
281
|
+
interval = min(interval + 5, 30)
|
|
282
|
+
await asyncio.sleep(interval)
|
|
283
|
+
continue
|
|
284
|
+
elif error == "expired_token":
|
|
285
|
+
raise AuthError("Device code expired. Please try again.")
|
|
286
|
+
elif error == "access_denied":
|
|
287
|
+
raise AuthError("Authorization denied by user.")
|
|
288
|
+
|
|
289
|
+
except httpx.RequestError:
|
|
290
|
+
await asyncio.sleep(interval)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
await asyncio.sleep(interval)
|
|
294
|
+
|
|
295
|
+
return None # Cancelled
|
|
296
|
+
|
|
297
|
+
except AuthError as e:
|
|
298
|
+
if self.on_error:
|
|
299
|
+
self.on_error(e)
|
|
300
|
+
raise
|