convo-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.
- convo/__init__.py +3 -0
- convo/__main__.py +110 -0
- convo/bootstrap.py +273 -0
- convo/core/__init__.py +1 -0
- convo/core/config.py +53 -0
- convo/core/contracts.py +160 -0
- convo/core/exceptions.py +5 -0
- convo/db/__init__.py +1 -0
- convo/db/base.py +13 -0
- convo/db/chunks.py +132 -0
- convo/db/conversations.py +167 -0
- convo/db/documents.py +34 -0
- convo/db/models.py +81 -0
- convo/db/session.py +27 -0
- convo/llm/__init__.py +0 -0
- convo/llm/auth.py +504 -0
- convo/llm/client.py +207 -0
- convo/llm/credentials.py +192 -0
- convo/llm/models.py +71 -0
- convo/llm/streaming.py +62 -0
- convo/migrations/env.py +43 -0
- convo/migrations/script.py.mako +22 -0
- convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
- convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
- convo/migrations/versions/README.md +3 -0
- convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
- convo/pipeline/__init__.py +0 -0
- convo/pipeline/chat.py +129 -0
- convo/pipeline/chunking.py +42 -0
- convo/pipeline/embeddings.py +83 -0
- convo/pipeline/index.py +160 -0
- convo/pipeline/ingestion.py +46 -0
- convo/pipeline/reranking.py +63 -0
- convo/pipeline/routing.py +64 -0
- convo/pipeline/sessions.py +40 -0
- convo/pipeline/worker.py +226 -0
- convo/prompts/__init__.py +0 -0
- convo/prompts/answers.py +97 -0
- convo/prompts/schema.py +97 -0
- convo/ui/__init__.py +1 -0
- convo/ui/app.py +752 -0
- convo/ui/commands.py +37 -0
- convo/ui/files.py +110 -0
- convo/ui/models.py +84 -0
- convo/ui/preview.py +20 -0
- convo/ui/sessions.py +97 -0
- convo/ui/theme.tcss +64 -0
- convo/ui/thinking.py +70 -0
- convo_cli-0.1.0.dist-info/METADATA +160 -0
- convo_cli-0.1.0.dist-info/RECORD +53 -0
- convo_cli-0.1.0.dist-info/WHEEL +4 -0
- convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/llm/auth.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Sign in with ChatGPT and refresh its locally stored OAuth credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
import webbrowser
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import jwt
|
|
20
|
+
|
|
21
|
+
from convo.core.contracts import ReasoningEffort
|
|
22
|
+
from convo.core.exceptions import BackendNotConnected
|
|
23
|
+
from convo.llm.credentials import (
|
|
24
|
+
AccountProfile,
|
|
25
|
+
AccountProfileStore,
|
|
26
|
+
CredentialStore,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize"
|
|
30
|
+
TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token"
|
|
31
|
+
OIDC_DISCOVERY_URL = "https://auth.openai.com/.well-known/openid-configuration"
|
|
32
|
+
RESOURCE = "https://api.openai.com/v1"
|
|
33
|
+
SCOPES = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
|
|
34
|
+
DIRECT_USE_SCOPE = "chatgpt.tokens.use.direct"
|
|
35
|
+
CALLBACK_PATH = "/auth/callback"
|
|
36
|
+
TOKEN_KEY = "chatgpt.oauth.tokens"
|
|
37
|
+
CALLBACK_TIMEOUT_SECONDS = 180
|
|
38
|
+
HTTP_TIMEOUT_SECONDS = 20
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AuthorizationResult:
|
|
43
|
+
code: str
|
|
44
|
+
client_id: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChatGPTAuth:
|
|
48
|
+
"""Own the loopback OAuth flow and the local ChatGPT account connection."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
credentials: CredentialStore,
|
|
53
|
+
profile_store: AccountProfileStore | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.credentials = credentials
|
|
56
|
+
self.profiles = profile_store or AccountProfileStore()
|
|
57
|
+
self._callback_future: asyncio.Future[AuthorizationResult] | None = None
|
|
58
|
+
self._login_lock = asyncio.Lock()
|
|
59
|
+
|
|
60
|
+
async def login(self) -> str:
|
|
61
|
+
"""Open the system browser, validate OAuth, and save the account locally."""
|
|
62
|
+
async with self._login_lock:
|
|
63
|
+
return await self._login()
|
|
64
|
+
|
|
65
|
+
async def _login(self) -> str:
|
|
66
|
+
try:
|
|
67
|
+
profile = self._load_profile()
|
|
68
|
+
old_tokens = self._load_tokens(required=False)
|
|
69
|
+
result, redirect_uri, nonce, verifier = await self._authorize(profile, old_tokens)
|
|
70
|
+
payload = await self._post_token(
|
|
71
|
+
{
|
|
72
|
+
"grant_type": "authorization_code",
|
|
73
|
+
"client_id": result.client_id,
|
|
74
|
+
"code": result.code,
|
|
75
|
+
"redirect_uri": redirect_uri,
|
|
76
|
+
"code_verifier": verifier,
|
|
77
|
+
"resource": RESOURCE,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
identity, issuer = await self._verify_id_token(
|
|
81
|
+
self._required_string(payload, "id_token"), result.client_id, nonce
|
|
82
|
+
)
|
|
83
|
+
if profile is not None and identity["sub"] != profile.subject:
|
|
84
|
+
raise BackendNotConnected(
|
|
85
|
+
"This ChatGPT registration belongs to a different account. "
|
|
86
|
+
"Remove the saved Convo account before switching accounts."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
scopes = _parse_scopes(payload.get("scope"))
|
|
90
|
+
self._require_direct_use(scopes)
|
|
91
|
+
self._validate_token_type(payload)
|
|
92
|
+
expires_in = self._expires_in(payload)
|
|
93
|
+
token_record = {
|
|
94
|
+
"access_token": self._required_string(payload, "access_token"),
|
|
95
|
+
"refresh_token": self._required_string(payload, "refresh_token"),
|
|
96
|
+
"id_token": self._required_string(payload, "id_token"),
|
|
97
|
+
}
|
|
98
|
+
new_profile = AccountProfile(
|
|
99
|
+
client_id=result.client_id,
|
|
100
|
+
subject=identity["sub"],
|
|
101
|
+
email=_optional_claim(identity, "email"),
|
|
102
|
+
name=_optional_claim(identity, "name")
|
|
103
|
+
or _optional_claim(identity, "preferred_username"),
|
|
104
|
+
issuer=issuer,
|
|
105
|
+
scopes=tuple(sorted(scopes)),
|
|
106
|
+
expires_at=time.time() + expires_in,
|
|
107
|
+
selected_model=profile.selected_model if profile else None,
|
|
108
|
+
selected_model_name=profile.selected_model_name if profile else None,
|
|
109
|
+
selected_reasoning_effort=profile.selected_reasoning_effort if profile else None,
|
|
110
|
+
)
|
|
111
|
+
self._save_connection(token_record, new_profile)
|
|
112
|
+
return new_profile.email or new_profile.name or new_profile.subject
|
|
113
|
+
except BackendNotConnected:
|
|
114
|
+
raise
|
|
115
|
+
except (httpx.HTTPError, OSError, ValueError, jwt.PyJWTError, KeyError, TypeError) as error:
|
|
116
|
+
# Raw HTTP/JWT exceptions can contain request details. Keep the UI response safe.
|
|
117
|
+
raise BackendNotConnected(
|
|
118
|
+
"ChatGPT sign-in could not be completed. Check the connection and retry /login."
|
|
119
|
+
) from error
|
|
120
|
+
|
|
121
|
+
async def access_token(self) -> str:
|
|
122
|
+
"""Return a valid, plan-enabled token, refreshing it when near expiry."""
|
|
123
|
+
profile = self._load_profile()
|
|
124
|
+
if profile is None:
|
|
125
|
+
raise BackendNotConnected("Connect your ChatGPT account with /login")
|
|
126
|
+
self._require_direct_use(set(profile.scopes))
|
|
127
|
+
tokens = self._load_tokens(required=True)
|
|
128
|
+
assert tokens is not None
|
|
129
|
+
access_token = tokens.get("access_token")
|
|
130
|
+
if not isinstance(access_token, str) or not access_token:
|
|
131
|
+
raise BackendNotConnected("Saved ChatGPT credentials are incomplete. Run /login again.")
|
|
132
|
+
if profile.expires_at > time.time() + 60:
|
|
133
|
+
return access_token
|
|
134
|
+
return await self._refresh(profile, tokens)
|
|
135
|
+
|
|
136
|
+
def account_profile(self) -> AccountProfile | None:
|
|
137
|
+
"""Return the non-secret signed-in account metadata, if available."""
|
|
138
|
+
return self._load_profile()
|
|
139
|
+
|
|
140
|
+
def save_generation_preferences(
|
|
141
|
+
self, model: str, display_name: str, effort: ReasoningEffort | None
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Persist model and reasoning together without altering OAuth credentials."""
|
|
144
|
+
profile = self._load_profile()
|
|
145
|
+
if profile is None:
|
|
146
|
+
raise BackendNotConnected("Connect your ChatGPT account with /login")
|
|
147
|
+
self.profiles.save(
|
|
148
|
+
replace(
|
|
149
|
+
profile,
|
|
150
|
+
selected_model=model,
|
|
151
|
+
selected_model_name=display_name,
|
|
152
|
+
selected_reasoning_effort=effort,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def _authorize(
|
|
157
|
+
self,
|
|
158
|
+
profile: AccountProfile | None,
|
|
159
|
+
old_tokens: dict[str, str] | None,
|
|
160
|
+
) -> tuple[AuthorizationResult, str, str, str]:
|
|
161
|
+
state = secrets.token_urlsafe(32)
|
|
162
|
+
nonce = secrets.token_urlsafe(32)
|
|
163
|
+
verifier = secrets.token_urlsafe(64)
|
|
164
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
165
|
+
client_id = profile.client_id if profile else "dynamic_agent_client"
|
|
166
|
+
if self._callback_future is not None:
|
|
167
|
+
raise BackendNotConnected("A ChatGPT sign-in is already in progress.")
|
|
168
|
+
|
|
169
|
+
server = await asyncio.start_server(
|
|
170
|
+
lambda reader, writer: self._handle_callback(reader, writer, state, client_id),
|
|
171
|
+
host="127.0.0.1",
|
|
172
|
+
port=0,
|
|
173
|
+
limit=16 * 1024,
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
socket = server.sockets[0]
|
|
177
|
+
port = socket.getsockname()[1]
|
|
178
|
+
redirect_uri = f"http://127.0.0.1:{port}{CALLBACK_PATH}"
|
|
179
|
+
params = {
|
|
180
|
+
"client_id": client_id,
|
|
181
|
+
"redirect_uri": redirect_uri,
|
|
182
|
+
"response_type": "code",
|
|
183
|
+
"scope": SCOPES,
|
|
184
|
+
"resource": RESOURCE,
|
|
185
|
+
"state": state,
|
|
186
|
+
"nonce": nonce,
|
|
187
|
+
"code_challenge": challenge.decode("ascii").rstrip("="),
|
|
188
|
+
"code_challenge_method": "S256",
|
|
189
|
+
"ext_agent_host_id": self.profiles.host_id(),
|
|
190
|
+
}
|
|
191
|
+
if profile is None:
|
|
192
|
+
params["agent_name_hint"] = "convo"
|
|
193
|
+
else:
|
|
194
|
+
email = profile.email
|
|
195
|
+
old_id_token = old_tokens.get("id_token") if old_tokens else None
|
|
196
|
+
if email:
|
|
197
|
+
params["login_hint"] = email
|
|
198
|
+
if old_id_token:
|
|
199
|
+
params["id_token_hint"] = old_id_token
|
|
200
|
+
authorization_url = f"{AUTHORIZE_URL}?{urlencode(params)}"
|
|
201
|
+
callback_future: asyncio.Future[AuthorizationResult] = (
|
|
202
|
+
asyncio.get_running_loop().create_future()
|
|
203
|
+
)
|
|
204
|
+
# Store the future only for the duration of this single authorization attempt.
|
|
205
|
+
self._callback_future = callback_future
|
|
206
|
+
opened = await asyncio.to_thread(webbrowser.open, authorization_url, 1, True)
|
|
207
|
+
if not opened:
|
|
208
|
+
raise BackendNotConnected(
|
|
209
|
+
"Could not open a browser for ChatGPT sign-in. Check your desktop browser and retry."
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
result = await asyncio.wait_for(callback_future, timeout=CALLBACK_TIMEOUT_SECONDS)
|
|
213
|
+
except TimeoutError as error:
|
|
214
|
+
raise BackendNotConnected(
|
|
215
|
+
"Timed out waiting for ChatGPT sign-in. Run /login and try again."
|
|
216
|
+
) from error
|
|
217
|
+
if profile is None and result.client_id == "dynamic_agent_client":
|
|
218
|
+
raise BackendNotConnected("ChatGPT did not return the registered client ID.")
|
|
219
|
+
return AuthorizationResult(result.code, result.client_id), redirect_uri, nonce, verifier
|
|
220
|
+
finally:
|
|
221
|
+
self._callback_future = None
|
|
222
|
+
server.close()
|
|
223
|
+
await server.wait_closed()
|
|
224
|
+
|
|
225
|
+
async def _handle_callback(
|
|
226
|
+
self,
|
|
227
|
+
reader: asyncio.StreamReader,
|
|
228
|
+
writer: asyncio.StreamWriter,
|
|
229
|
+
expected_state: str,
|
|
230
|
+
requested_client_id: str,
|
|
231
|
+
) -> None:
|
|
232
|
+
response_status = "400 Bad Request"
|
|
233
|
+
response_body = "Sign-in callback was not accepted. Return to Convo and retry /login."
|
|
234
|
+
should_resolve = False
|
|
235
|
+
try:
|
|
236
|
+
request_line = await asyncio.wait_for(reader.readline(), timeout=10)
|
|
237
|
+
try:
|
|
238
|
+
method, target, _version = request_line.decode("ascii").strip().split(" ", 2)
|
|
239
|
+
except (UnicodeDecodeError, ValueError):
|
|
240
|
+
method, target = "", ""
|
|
241
|
+
total_header_bytes = 0
|
|
242
|
+
while True:
|
|
243
|
+
line = await asyncio.wait_for(reader.readline(), timeout=10)
|
|
244
|
+
total_header_bytes += len(line)
|
|
245
|
+
if not line or line in (b"\r\n", b"\n"):
|
|
246
|
+
break
|
|
247
|
+
if total_header_bytes > 16 * 1024:
|
|
248
|
+
raise ValueError("Callback headers are too large")
|
|
249
|
+
parsed = urlsplit(target)
|
|
250
|
+
if method != "GET" or parsed.path != CALLBACK_PATH:
|
|
251
|
+
response_status = "404 Not Found"
|
|
252
|
+
response_body = "Unknown Convo callback path. You may close this tab."
|
|
253
|
+
else:
|
|
254
|
+
query = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True)
|
|
255
|
+
state_values = query.get("state", [])
|
|
256
|
+
if len(state_values) != 1 or not hmac.compare_digest(
|
|
257
|
+
state_values[0], expected_state
|
|
258
|
+
):
|
|
259
|
+
response_body = "This sign-in callback did not match the pending request."
|
|
260
|
+
else:
|
|
261
|
+
errors = query.get("error", [])
|
|
262
|
+
if errors:
|
|
263
|
+
response_status = "200 OK"
|
|
264
|
+
response_body = "ChatGPT sign-in was canceled. You may close this tab."
|
|
265
|
+
future = self._callback_future
|
|
266
|
+
if future is not None and not future.done():
|
|
267
|
+
future.set_exception(
|
|
268
|
+
BackendNotConnected("ChatGPT sign-in was canceled or denied.")
|
|
269
|
+
)
|
|
270
|
+
should_resolve = True
|
|
271
|
+
else:
|
|
272
|
+
codes = query.get("code", [])
|
|
273
|
+
client_ids = query.get("client_id", [])
|
|
274
|
+
if len(codes) != 1 or not codes[0]:
|
|
275
|
+
raise ValueError("Callback did not contain one authorization code")
|
|
276
|
+
if len(client_ids) > 1:
|
|
277
|
+
raise ValueError("Callback contained duplicate client IDs")
|
|
278
|
+
callback_client_id = client_ids[0] if client_ids else requested_client_id
|
|
279
|
+
if not callback_client_id or (
|
|
280
|
+
requested_client_id != "dynamic_agent_client"
|
|
281
|
+
and callback_client_id != requested_client_id
|
|
282
|
+
):
|
|
283
|
+
raise ValueError("Callback client ID did not match the request")
|
|
284
|
+
response_status = "200 OK"
|
|
285
|
+
response_body = "ChatGPT is connected to Convo. You may close this tab."
|
|
286
|
+
future = self._callback_future
|
|
287
|
+
if future is not None and not future.done():
|
|
288
|
+
future.set_result(AuthorizationResult(codes[0], callback_client_id))
|
|
289
|
+
should_resolve = True
|
|
290
|
+
except (TimeoutError, ValueError):
|
|
291
|
+
response_status = "400 Bad Request"
|
|
292
|
+
finally:
|
|
293
|
+
body = (
|
|
294
|
+
"<!doctype html><html><meta charset='utf-8'><title>Convo</title>"
|
|
295
|
+
f"<body style='font:16px sans-serif;padding:2rem'>{response_body}</body></html>"
|
|
296
|
+
).encode()
|
|
297
|
+
writer.write(
|
|
298
|
+
f"HTTP/1.1 {response_status}\r\n"
|
|
299
|
+
"Content-Type: text/html; charset=utf-8\r\n"
|
|
300
|
+
f"Content-Length: {len(body)}\r\n"
|
|
301
|
+
"Connection: close\r\n\r\n".encode("ascii")
|
|
302
|
+
+ body
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
await writer.drain()
|
|
306
|
+
finally:
|
|
307
|
+
writer.close()
|
|
308
|
+
await writer.wait_closed()
|
|
309
|
+
if should_resolve:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
async def _post_token(self, data: dict[str, str]) -> dict[str, Any]:
|
|
313
|
+
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
|
314
|
+
response = await client.post(TOKEN_URL, data=data)
|
|
315
|
+
response.raise_for_status()
|
|
316
|
+
payload = response.json()
|
|
317
|
+
if not isinstance(payload, dict):
|
|
318
|
+
raise ValueError("Token endpoint returned an invalid response")
|
|
319
|
+
return payload
|
|
320
|
+
|
|
321
|
+
async def _verify_id_token(
|
|
322
|
+
self, id_token: str, client_id: str, nonce: str | None
|
|
323
|
+
) -> tuple[dict[str, Any], str]:
|
|
324
|
+
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
|
325
|
+
response = await client.get(OIDC_DISCOVERY_URL)
|
|
326
|
+
response.raise_for_status()
|
|
327
|
+
discovery = response.json()
|
|
328
|
+
if not isinstance(discovery, dict):
|
|
329
|
+
raise ValueError("OpenID discovery response is invalid")
|
|
330
|
+
issuer = discovery.get("issuer")
|
|
331
|
+
jwks_uri = discovery.get("jwks_uri")
|
|
332
|
+
if not isinstance(issuer, str) or not isinstance(jwks_uri, str):
|
|
333
|
+
raise ValueError("OpenID discovery document is missing required fields")
|
|
334
|
+
jwks_url = urlsplit(jwks_uri)
|
|
335
|
+
if jwks_url.scheme != "https" or jwks_url.hostname != "auth.openai.com":
|
|
336
|
+
raise ValueError("OpenID discovery returned an untrusted signing-key URL")
|
|
337
|
+
keys_response = await client.get(jwks_uri)
|
|
338
|
+
keys_response.raise_for_status()
|
|
339
|
+
key_set = keys_response.json()
|
|
340
|
+
|
|
341
|
+
if not isinstance(key_set, dict) or not isinstance(key_set.get("keys"), list):
|
|
342
|
+
raise ValueError("OpenID signing-key response is invalid")
|
|
343
|
+
header = jwt.get_unverified_header(id_token)
|
|
344
|
+
key_id = header.get("kid")
|
|
345
|
+
if header.get("alg") != "RS256" or not isinstance(key_id, str):
|
|
346
|
+
raise ValueError("ID token uses an unsupported signing key")
|
|
347
|
+
jwk = next(
|
|
348
|
+
(
|
|
349
|
+
item
|
|
350
|
+
for item in key_set["keys"]
|
|
351
|
+
if isinstance(item, dict) and item.get("kid") == key_id
|
|
352
|
+
),
|
|
353
|
+
None,
|
|
354
|
+
)
|
|
355
|
+
if jwk is None:
|
|
356
|
+
raise ValueError("ID token signing key was not found")
|
|
357
|
+
signing_key = jwt.PyJWK.from_dict(jwk, algorithm="RS256").key
|
|
358
|
+
claims = jwt.decode(
|
|
359
|
+
id_token,
|
|
360
|
+
signing_key,
|
|
361
|
+
algorithms=["RS256"],
|
|
362
|
+
audience=client_id,
|
|
363
|
+
issuer=issuer,
|
|
364
|
+
options={"require": ["exp", "iat", "sub", "iss", "aud"]},
|
|
365
|
+
)
|
|
366
|
+
if not isinstance(claims.get("sub"), str) or not claims["sub"]:
|
|
367
|
+
raise ValueError("ID token has no subject")
|
|
368
|
+
if nonce is not None and not hmac.compare_digest(str(claims.get("nonce", "")), nonce):
|
|
369
|
+
raise ValueError("ID token nonce did not match")
|
|
370
|
+
return claims, issuer
|
|
371
|
+
|
|
372
|
+
async def _refresh(self, profile: AccountProfile, tokens: dict[str, str]) -> str:
|
|
373
|
+
refresh_token = tokens.get("refresh_token")
|
|
374
|
+
if not refresh_token:
|
|
375
|
+
raise BackendNotConnected("ChatGPT session expired. Run /login again.")
|
|
376
|
+
try:
|
|
377
|
+
payload = await self._post_token(
|
|
378
|
+
{
|
|
379
|
+
"grant_type": "refresh_token",
|
|
380
|
+
"client_id": profile.client_id,
|
|
381
|
+
"refresh_token": refresh_token,
|
|
382
|
+
"resource": RESOURCE,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
scopes = _parse_scopes(payload.get("scope")) or set(profile.scopes)
|
|
386
|
+
self._require_direct_use(scopes)
|
|
387
|
+
self._validate_token_type(payload)
|
|
388
|
+
expires_in = self._expires_in(payload)
|
|
389
|
+
access_token = self._required_string(payload, "access_token")
|
|
390
|
+
new_refresh_token = payload.get("refresh_token", refresh_token)
|
|
391
|
+
if not isinstance(new_refresh_token, str) or not new_refresh_token:
|
|
392
|
+
raise ValueError("Refresh endpoint returned no refresh token")
|
|
393
|
+
new_id_token = payload.get("id_token", tokens.get("id_token", ""))
|
|
394
|
+
if not isinstance(new_id_token, str) or not new_id_token:
|
|
395
|
+
raise ValueError("Saved ID token is missing")
|
|
396
|
+
if payload.get("id_token"):
|
|
397
|
+
identity, issuer = await self._verify_id_token(
|
|
398
|
+
new_id_token, profile.client_id, nonce=None
|
|
399
|
+
)
|
|
400
|
+
if identity["sub"] != profile.subject:
|
|
401
|
+
raise ValueError("Refreshed ID token belongs to a different account")
|
|
402
|
+
if issuer != profile.issuer:
|
|
403
|
+
raise ValueError("Refreshed ID token issuer changed")
|
|
404
|
+
token_record = {
|
|
405
|
+
"access_token": access_token,
|
|
406
|
+
"refresh_token": new_refresh_token,
|
|
407
|
+
"id_token": new_id_token,
|
|
408
|
+
}
|
|
409
|
+
updated_profile = replace(
|
|
410
|
+
profile,
|
|
411
|
+
scopes=tuple(sorted(scopes)),
|
|
412
|
+
expires_at=time.time() + expires_in,
|
|
413
|
+
)
|
|
414
|
+
self._save_connection(token_record, updated_profile)
|
|
415
|
+
return access_token
|
|
416
|
+
except BackendNotConnected:
|
|
417
|
+
raise
|
|
418
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as error:
|
|
419
|
+
raise BackendNotConnected(
|
|
420
|
+
"ChatGPT session expired. Run /login to reconnect your account."
|
|
421
|
+
) from error
|
|
422
|
+
|
|
423
|
+
def _save_connection(self, tokens: dict[str, str], profile: AccountProfile) -> None:
|
|
424
|
+
try:
|
|
425
|
+
self.credentials.save(TOKEN_KEY, json.dumps(tokens, separators=(",", ":")))
|
|
426
|
+
self.profiles.save(profile)
|
|
427
|
+
except Exception as error: # keyring backends expose platform-specific exceptions
|
|
428
|
+
raise BackendNotConnected(
|
|
429
|
+
"Could not securely save ChatGPT credentials. Check your operating-system keyring."
|
|
430
|
+
) from error
|
|
431
|
+
|
|
432
|
+
def _load_profile(self) -> AccountProfile | None:
|
|
433
|
+
try:
|
|
434
|
+
return self.profiles.load()
|
|
435
|
+
except (OSError, ValueError) as error:
|
|
436
|
+
raise BackendNotConnected(
|
|
437
|
+
"Saved ChatGPT profile is unreadable. Fix or remove it, then run /login again."
|
|
438
|
+
) from error
|
|
439
|
+
|
|
440
|
+
def _load_tokens(self, *, required: bool) -> dict[str, str] | None:
|
|
441
|
+
try:
|
|
442
|
+
raw = self.credentials.load(TOKEN_KEY)
|
|
443
|
+
except Exception as error: # keyring backends expose platform-specific exceptions
|
|
444
|
+
raise BackendNotConnected(
|
|
445
|
+
"Could not access the operating-system keyring for ChatGPT credentials."
|
|
446
|
+
) from error
|
|
447
|
+
if raw is None:
|
|
448
|
+
if required:
|
|
449
|
+
raise BackendNotConnected("Connect your ChatGPT account with /login")
|
|
450
|
+
return None
|
|
451
|
+
try:
|
|
452
|
+
tokens = json.loads(raw)
|
|
453
|
+
except json.JSONDecodeError as error:
|
|
454
|
+
raise BackendNotConnected(
|
|
455
|
+
"Saved ChatGPT credentials are invalid. Run /login again."
|
|
456
|
+
) from error
|
|
457
|
+
if not isinstance(tokens, dict) or not all(
|
|
458
|
+
isinstance(tokens.get(key), str)
|
|
459
|
+
for key in ("access_token", "refresh_token", "id_token")
|
|
460
|
+
):
|
|
461
|
+
raise BackendNotConnected("Saved ChatGPT credentials are incomplete. Run /login again.")
|
|
462
|
+
return tokens
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _required_string(payload: dict[str, Any], name: str) -> str:
|
|
466
|
+
value = payload.get(name)
|
|
467
|
+
if not isinstance(value, str) or not value:
|
|
468
|
+
raise ValueError(f"Token response is missing {name}")
|
|
469
|
+
return value
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def _expires_in(payload: dict[str, Any]) -> int:
|
|
473
|
+
value = payload.get("expires_in")
|
|
474
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
475
|
+
raise ValueError("Token response has an invalid expiry")
|
|
476
|
+
if not math.isfinite(value) or value <= 0:
|
|
477
|
+
raise ValueError("Token response has an invalid expiry")
|
|
478
|
+
return int(value)
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _validate_token_type(payload: dict[str, Any]) -> None:
|
|
482
|
+
token_type = payload.get("token_type", "Bearer")
|
|
483
|
+
if not isinstance(token_type, str) or token_type.casefold() != "bearer":
|
|
484
|
+
raise ValueError("Token endpoint returned an unsupported token type")
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _require_direct_use(scopes: set[str]) -> None:
|
|
488
|
+
if DIRECT_USE_SCOPE not in scopes:
|
|
489
|
+
raise BackendNotConnected(
|
|
490
|
+
"This ChatGPT account did not grant direct model access. Run /login and allow it."
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _parse_scopes(value: object) -> set[str]:
|
|
495
|
+
if isinstance(value, str):
|
|
496
|
+
return set(value.split())
|
|
497
|
+
if isinstance(value, list) and all(isinstance(scope, str) for scope in value):
|
|
498
|
+
return set(value)
|
|
499
|
+
return set()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _optional_claim(claims: dict[str, Any], key: str) -> str | None:
|
|
503
|
+
value = claims.get(key)
|
|
504
|
+
return value if isinstance(value, str) and value else None
|