pi-python-core 0.8.1__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.
- pi_python/__init__.py +160 -0
- pi_python/_version.py +1 -0
- pi_python/agent.py +396 -0
- pi_python/cancellation.py +24 -0
- pi_python/data/models.json +3315 -0
- pi_python/errors.py +49 -0
- pi_python/estimate.py +144 -0
- pi_python/events.py +138 -0
- pi_python/function_tools.py +438 -0
- pi_python/hooks.py +44 -0
- pi_python/limits.py +28 -0
- pi_python/loop.py +431 -0
- pi_python/lowlevel.py +179 -0
- pi_python/mcp.py +187 -0
- pi_python/messages.py +405 -0
- pi_python/models.py +155 -0
- pi_python/provider.py +123 -0
- pi_python/providers/__init__.py +21 -0
- pi_python/providers/anthropic.py +673 -0
- pi_python/providers/common.py +201 -0
- pi_python/providers/completions.py +1149 -0
- pi_python/providers/oauth.py +542 -0
- pi_python/providers/openai.py +681 -0
- pi_python/providers/transport.py +574 -0
- pi_python/proxy.py +304 -0
- pi_python/py.typed +0 -0
- pi_python/queues.py +76 -0
- pi_python/recovery.py +209 -0
- pi_python/run.py +419 -0
- pi_python/stream.py +251 -0
- pi_python/sync.py +78 -0
- pi_python/testing.py +25 -0
- pi_python/tools.py +546 -0
- pi_python/transcript.py +167 -0
- pi_python_core-0.8.1.dist-info/METADATA +119 -0
- pi_python_core-0.8.1.dist-info/RECORD +39 -0
- pi_python_core-0.8.1.dist-info/WHEEL +4 -0
- pi_python_core-0.8.1.dist-info/licenses/LICENSE +21 -0
- pi_python_core-0.8.1.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Explicit OAuth login and refresh. No ambient credentials or automatic browser access.
|
|
2
|
+
|
|
3
|
+
Endpoint/client constants follow Pi v1.0.0. ChatGPT ID token validation also follows
|
|
4
|
+
OpenAI's public sign-in documentation. Applications own credential persistence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import math
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
19
|
+
from ..cancellation import CancelToken
|
|
20
|
+
from ..errors import ConfigurationError, ProviderProtocolError
|
|
21
|
+
from ..tools import invoke
|
|
22
|
+
from .transport import HTTPTransport, ProviderHTTPError, cancellable
|
|
23
|
+
from .openai import account_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class OAuthCredential:
|
|
28
|
+
provider: str
|
|
29
|
+
access_token: str = field(repr=False)
|
|
30
|
+
refresh_token: str = field(repr=False)
|
|
31
|
+
expires_at: float
|
|
32
|
+
client_id: str = ""
|
|
33
|
+
scopes: tuple[str, ...] = ()
|
|
34
|
+
account_id: str | None = None
|
|
35
|
+
subject: str | None = None
|
|
36
|
+
id_token: str | None = field(default=None, repr=False)
|
|
37
|
+
host_id: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class OAuthAttempt:
|
|
42
|
+
provider: str
|
|
43
|
+
authorize_url: str = field(repr=False)
|
|
44
|
+
redirect_uri: str
|
|
45
|
+
client_id: str
|
|
46
|
+
verifier: str = field(repr=False)
|
|
47
|
+
state: str = field(repr=False)
|
|
48
|
+
nonce: str = field(repr=False)
|
|
49
|
+
host_id: str | None = None
|
|
50
|
+
expected_subject: str | None = None
|
|
51
|
+
consumed: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SETTINGS = {
|
|
55
|
+
"anthropic": (
|
|
56
|
+
"https://claude.ai/oauth/authorize",
|
|
57
|
+
"https://platform.claude.com/v1/oauth/token",
|
|
58
|
+
"9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
59
|
+
"http://localhost:53692/callback",
|
|
60
|
+
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
|
|
61
|
+
),
|
|
62
|
+
"openai-codex": (
|
|
63
|
+
"https://auth.openai.com/oauth/authorize",
|
|
64
|
+
"https://auth.openai.com/oauth/token",
|
|
65
|
+
"app_EMoamEEZ73f0CkXaXp7hrann",
|
|
66
|
+
"http://localhost:1455/auth/callback",
|
|
67
|
+
"openid profile email offline_access",
|
|
68
|
+
),
|
|
69
|
+
"openai-chatgpt": (
|
|
70
|
+
"https://auth.openai.com/api/accounts/authorize",
|
|
71
|
+
"https://auth.openai.com/api/accounts/oauth/token",
|
|
72
|
+
"dynamic_agent_client",
|
|
73
|
+
"http://127.0.0.1:1455/auth/callback",
|
|
74
|
+
"openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
_RESOURCE = "https://api.openai.com/v1"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OAuthClient:
|
|
81
|
+
def __init__(self, provider: str, *, transport: HTTPTransport | None = None) -> None:
|
|
82
|
+
if provider not in _SETTINGS:
|
|
83
|
+
raise ConfigurationError("Unknown OAuth provider")
|
|
84
|
+
self.provider = provider
|
|
85
|
+
self.transport = transport or HTTPTransport()
|
|
86
|
+
|
|
87
|
+
def begin(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
method: str = "browser",
|
|
91
|
+
host_id: str | None = None,
|
|
92
|
+
credential: OAuthCredential | None = None,
|
|
93
|
+
redirect_uri: str | None = None,
|
|
94
|
+
) -> OAuthAttempt:
|
|
95
|
+
authorize, _, clientid, redirect, scope = _SETTINGS[self.provider]
|
|
96
|
+
if method not in {"browser", "copy_code"} or (
|
|
97
|
+
method == "copy_code" and self.provider != "anthropic"
|
|
98
|
+
):
|
|
99
|
+
raise ConfigurationError("copy_code is supported only for Anthropic")
|
|
100
|
+
if method == "copy_code":
|
|
101
|
+
redirect = "https://platform.claude.com/oauth/code/callback"
|
|
102
|
+
if redirect_uri:
|
|
103
|
+
given, expected = urlsplit(redirect_uri), urlsplit(redirect)
|
|
104
|
+
if (
|
|
105
|
+
self.provider != "openai-chatgpt"
|
|
106
|
+
or (given.scheme, given.hostname, given.path)
|
|
107
|
+
!= (expected.scheme, expected.hostname, expected.path)
|
|
108
|
+
or given.query
|
|
109
|
+
or given.fragment
|
|
110
|
+
or given.username
|
|
111
|
+
or given.password
|
|
112
|
+
):
|
|
113
|
+
raise ConfigurationError("Only ChatGPT loopback port may be overridden")
|
|
114
|
+
redirect = redirect_uri
|
|
115
|
+
verifier = secrets.token_urlsafe(48)
|
|
116
|
+
state = verifier if self.provider == "anthropic" else secrets.token_urlsafe(32)
|
|
117
|
+
nonce = secrets.token_urlsafe(32)
|
|
118
|
+
challenge = (
|
|
119
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
120
|
+
.rstrip(b"=")
|
|
121
|
+
.decode()
|
|
122
|
+
)
|
|
123
|
+
params = {
|
|
124
|
+
"client_id": clientid,
|
|
125
|
+
"response_type": "code",
|
|
126
|
+
"redirect_uri": redirect,
|
|
127
|
+
"scope": scope,
|
|
128
|
+
"code_challenge": challenge,
|
|
129
|
+
"code_challenge_method": "S256",
|
|
130
|
+
"state": state,
|
|
131
|
+
}
|
|
132
|
+
expected_subject = None
|
|
133
|
+
if credential:
|
|
134
|
+
if credential.provider != self.provider:
|
|
135
|
+
raise ConfigurationError("Credential belongs to another provider")
|
|
136
|
+
if self.provider == "openai-chatgpt":
|
|
137
|
+
clientid = credential.client_id
|
|
138
|
+
expected_subject = credential.subject
|
|
139
|
+
params["client_id"] = clientid
|
|
140
|
+
if credential.id_token:
|
|
141
|
+
params["id_token_hint"] = credential.id_token
|
|
142
|
+
host_id = host_id or credential.host_id
|
|
143
|
+
if self.provider == "anthropic":
|
|
144
|
+
params["code"] = "true"
|
|
145
|
+
elif self.provider == "openai-codex":
|
|
146
|
+
params.update(
|
|
147
|
+
{
|
|
148
|
+
"id_token_add_organizations": "true",
|
|
149
|
+
"codex_cli_simplified_flow": "true",
|
|
150
|
+
"originator": "pi",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
if not host_id:
|
|
155
|
+
raise ConfigurationError(
|
|
156
|
+
"Provide a stable host_id (urn:uuid:...) and persist it for this installation"
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
host_id = "urn:uuid:" + str(uuid.UUID(host_id.removeprefix("urn:uuid:")))
|
|
160
|
+
except (ValueError, AttributeError) as exc:
|
|
161
|
+
raise ConfigurationError("Invalid host UUID") from exc
|
|
162
|
+
params.update({"ext_agent_host_id": host_id, "nonce": nonce, "resource": _RESOURCE})
|
|
163
|
+
if clientid == "dynamic_agent_client":
|
|
164
|
+
params["agent_name_hint"] = "pi-python"
|
|
165
|
+
return OAuthAttempt(
|
|
166
|
+
self.provider,
|
|
167
|
+
authorize + "?" + urlencode(params),
|
|
168
|
+
redirect,
|
|
169
|
+
clientid,
|
|
170
|
+
verifier,
|
|
171
|
+
state,
|
|
172
|
+
nonce,
|
|
173
|
+
host_id,
|
|
174
|
+
expected_subject,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _callback(self, attempt: OAuthAttempt, callback: str) -> tuple[str, str]:
|
|
178
|
+
if attempt.provider != self.provider or attempt.consumed:
|
|
179
|
+
raise ConfigurationError("OAuth attempt is invalid or already consumed")
|
|
180
|
+
if self.provider == "anthropic" and "#" in callback and "://" not in callback:
|
|
181
|
+
code, state = callback.strip().split("#", 1)
|
|
182
|
+
values = {"code": code, "state": state}
|
|
183
|
+
else:
|
|
184
|
+
actual, expected = urlsplit(callback.strip()), urlsplit(attempt.redirect_uri)
|
|
185
|
+
if (actual.scheme, actual.netloc, actual.path) != (
|
|
186
|
+
expected.scheme,
|
|
187
|
+
expected.netloc,
|
|
188
|
+
expected.path,
|
|
189
|
+
) or actual.fragment:
|
|
190
|
+
raise ConfigurationError("OAuth callback URI does not match")
|
|
191
|
+
query = parse_qs(actual.query, keep_blank_values=True)
|
|
192
|
+
if any(len(v) != 1 for v in query.values()):
|
|
193
|
+
raise ConfigurationError("Duplicate OAuth callback parameter")
|
|
194
|
+
values = {k: v[0] for k, v in query.items()}
|
|
195
|
+
if not secrets.compare_digest(values.get("state", ""), attempt.state):
|
|
196
|
+
raise ConfigurationError("OAuth state mismatch")
|
|
197
|
+
if values.get("error"):
|
|
198
|
+
attempt.consumed = True
|
|
199
|
+
raise ConfigurationError("OAuth authorization was declined or failed")
|
|
200
|
+
if not values.get("code"):
|
|
201
|
+
raise ConfigurationError("OAuth callback lacks code")
|
|
202
|
+
clientid = attempt.client_id
|
|
203
|
+
if self.provider == "openai-chatgpt":
|
|
204
|
+
returned = values.get("client_id")
|
|
205
|
+
if clientid == "dynamic_agent_client":
|
|
206
|
+
if not returned or returned == "dynamic_agent_client":
|
|
207
|
+
raise ConfigurationError("Dynamic registration lacks issued client ID")
|
|
208
|
+
clientid = returned
|
|
209
|
+
elif returned and returned != clientid:
|
|
210
|
+
raise ConfigurationError("OAuth client ID changed during reauthorization")
|
|
211
|
+
return values["code"], clientid
|
|
212
|
+
|
|
213
|
+
async def exchange(
|
|
214
|
+
self, attempt: OAuthAttempt, callback: str, cancel: CancelToken | None = None
|
|
215
|
+
) -> OAuthCredential:
|
|
216
|
+
cancel = cancel or CancelToken()
|
|
217
|
+
code, clientid = self._callback(attempt, callback)
|
|
218
|
+
attempt.consumed = True
|
|
219
|
+
body = {
|
|
220
|
+
"grant_type": "authorization_code",
|
|
221
|
+
"client_id": clientid,
|
|
222
|
+
"code": code,
|
|
223
|
+
"code_verifier": attempt.verifier,
|
|
224
|
+
"redirect_uri": attempt.redirect_uri,
|
|
225
|
+
}
|
|
226
|
+
if self.provider == "anthropic":
|
|
227
|
+
body["state"] = attempt.state
|
|
228
|
+
if self.provider == "openai-chatgpt":
|
|
229
|
+
body["resource"] = _RESOURCE
|
|
230
|
+
data = await self._token(body, cancel)
|
|
231
|
+
result = self._credential(data, clientid)
|
|
232
|
+
if self.provider == "openai-chatgpt":
|
|
233
|
+
claims = await self._verify_id_token(
|
|
234
|
+
data.get("id_token"), clientid, attempt.nonce, cancel
|
|
235
|
+
)
|
|
236
|
+
if attempt.expected_subject and claims["sub"] != attempt.expected_subject:
|
|
237
|
+
raise ConfigurationError("Reauthorization returned a different account")
|
|
238
|
+
result = replace(
|
|
239
|
+
result, subject=claims["sub"], id_token=data["id_token"], host_id=attempt.host_id
|
|
240
|
+
)
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
async def _token(self, body: dict[str, Any], cancel: CancelToken) -> dict[str, Any]:
|
|
244
|
+
return await self.transport.request_json(
|
|
245
|
+
_SETTINGS[self.provider][1], body, {}, cancel, form=self.provider != "anthropic"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _credential(self, data: dict[str, Any], clientid: str) -> OAuthCredential:
|
|
249
|
+
for key in ("access_token", "refresh_token"):
|
|
250
|
+
if not isinstance(data.get(key), str) or not data[key]:
|
|
251
|
+
raise ProviderProtocolError(f"OAuth response lacks {key}")
|
|
252
|
+
expires = data.get("expires_in")
|
|
253
|
+
if (
|
|
254
|
+
not isinstance(expires, (int, float))
|
|
255
|
+
or isinstance(expires, bool)
|
|
256
|
+
or not math.isfinite(expires)
|
|
257
|
+
or expires <= 0
|
|
258
|
+
):
|
|
259
|
+
raise ProviderProtocolError("Invalid OAuth expires_in")
|
|
260
|
+
scope = data.get("scope", "")
|
|
261
|
+
if not isinstance(scope, str):
|
|
262
|
+
raise ProviderProtocolError("Invalid OAuth scope")
|
|
263
|
+
scopes = tuple(scope.split())
|
|
264
|
+
if self.provider == "openai-chatgpt" and "chatgpt.tokens.use.direct" not in scopes:
|
|
265
|
+
raise ConfigurationError("OAuth grant lacks chatgpt.tokens.use.direct")
|
|
266
|
+
account = None
|
|
267
|
+
if self.provider == "openai-codex":
|
|
268
|
+
account = account_id(data["access_token"])
|
|
269
|
+
return OAuthCredential(
|
|
270
|
+
self.provider,
|
|
271
|
+
data["access_token"],
|
|
272
|
+
data["refresh_token"],
|
|
273
|
+
time.time() + expires,
|
|
274
|
+
clientid,
|
|
275
|
+
scopes,
|
|
276
|
+
account,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def _verify_id_token(
|
|
280
|
+
self, token: Any, clientid: str, nonce: str | None, cancel: CancelToken
|
|
281
|
+
) -> dict[str, Any]:
|
|
282
|
+
import jwt
|
|
283
|
+
|
|
284
|
+
if not isinstance(token, str) or not token:
|
|
285
|
+
raise ConfigurationError("ChatGPT sign-in lacks ID token")
|
|
286
|
+
try:
|
|
287
|
+
discovery = await self.transport.get_json(
|
|
288
|
+
"https://auth.openai.com/.well-known/openid-configuration", cancel
|
|
289
|
+
)
|
|
290
|
+
uri = discovery["jwks_uri"]
|
|
291
|
+
if urlsplit(uri).scheme != "https" or urlsplit(uri).hostname != "auth.openai.com":
|
|
292
|
+
raise ConfigurationError("Unexpected OpenAI JWKS origin")
|
|
293
|
+
keys = await self.transport.get_json(uri, cancel)
|
|
294
|
+
header = jwt.get_unverified_header(token)
|
|
295
|
+
if header.get("alg") != "RS256":
|
|
296
|
+
raise ConfigurationError("Unsupported ID token signing algorithm")
|
|
297
|
+
matches = [k for k in keys["keys"] if k.get("kid") == header.get("kid")]
|
|
298
|
+
if len(matches) != 1:
|
|
299
|
+
raise ConfigurationError("ID token signing key is unavailable")
|
|
300
|
+
key = jwt.PyJWK.from_dict(matches[0], algorithm="RS256").key
|
|
301
|
+
claims = jwt.decode(
|
|
302
|
+
token,
|
|
303
|
+
key,
|
|
304
|
+
algorithms=["RS256"],
|
|
305
|
+
audience=clientid,
|
|
306
|
+
issuer="https://auth.openai.com",
|
|
307
|
+
options={"require": ["exp", "iat", "sub", "iss", "aud"]},
|
|
308
|
+
)
|
|
309
|
+
if nonce is not None and not secrets.compare_digest(
|
|
310
|
+
str(claims.get("nonce", "")), nonce
|
|
311
|
+
):
|
|
312
|
+
raise ConfigurationError("ID token nonce mismatch")
|
|
313
|
+
if not isinstance(claims["sub"], str) or not claims["sub"]:
|
|
314
|
+
raise ConfigurationError("ID token subject missing")
|
|
315
|
+
return claims
|
|
316
|
+
except (jwt.PyJWTError, KeyError, TypeError, ValueError) as exc:
|
|
317
|
+
raise ConfigurationError("ID token validation failed") from exc
|
|
318
|
+
|
|
319
|
+
async def refresh(
|
|
320
|
+
self, credential: OAuthCredential, cancel: CancelToken | None = None
|
|
321
|
+
) -> OAuthCredential:
|
|
322
|
+
if credential.provider != self.provider:
|
|
323
|
+
raise ConfigurationError("Credential belongs to another provider")
|
|
324
|
+
cancel = cancel or CancelToken()
|
|
325
|
+
body = {
|
|
326
|
+
"grant_type": "refresh_token",
|
|
327
|
+
"client_id": credential.client_id or _SETTINGS[self.provider][2],
|
|
328
|
+
"refresh_token": credential.refresh_token,
|
|
329
|
+
}
|
|
330
|
+
if self.provider == "openai-chatgpt":
|
|
331
|
+
body["resource"] = _RESOURCE
|
|
332
|
+
data = await self._token(body, cancel)
|
|
333
|
+
result = self._credential(data, body["client_id"])
|
|
334
|
+
id_token = credential.id_token
|
|
335
|
+
if self.provider == "openai-chatgpt" and data.get("id_token"):
|
|
336
|
+
claims = await self._verify_id_token(data["id_token"], body["client_id"], None, cancel)
|
|
337
|
+
if claims["sub"] != credential.subject:
|
|
338
|
+
raise ConfigurationError("Refresh returned another account")
|
|
339
|
+
id_token = data["id_token"]
|
|
340
|
+
return replace(
|
|
341
|
+
result, subject=credential.subject, id_token=id_token, host_id=credential.host_id
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
async def login(
|
|
345
|
+
self,
|
|
346
|
+
on_auth_url: Callable[[str], Any],
|
|
347
|
+
*,
|
|
348
|
+
on_prompt: Callable[[str], Any] | None = None,
|
|
349
|
+
cancel: CancelToken | None = None,
|
|
350
|
+
timeout: float = 300,
|
|
351
|
+
**begin_options: Any,
|
|
352
|
+
) -> OAuthCredential:
|
|
353
|
+
"""Listen on loopback before notifying the UI. UI opens the URL explicitly.
|
|
354
|
+
|
|
355
|
+
Pass on_prompt(url)->full_callback_url for manual flow / busy-port fallback.
|
|
356
|
+
For Anthropic copy_code, return code#state. Nothing is printed or persisted.
|
|
357
|
+
"""
|
|
358
|
+
cancel = cancel or CancelToken()
|
|
359
|
+
attempt = self.begin(**begin_options)
|
|
360
|
+
uri = urlsplit(attempt.redirect_uri)
|
|
361
|
+
if begin_options.get("method") == "copy_code":
|
|
362
|
+
if on_prompt is None:
|
|
363
|
+
raise ConfigurationError("copy_code requires on_prompt")
|
|
364
|
+
await invoke(on_auth_url, attempt.authorize_url)
|
|
365
|
+
async with asyncio.timeout(timeout):
|
|
366
|
+
callback = await cancellable(invoke(on_prompt, attempt.redirect_uri), cancel)
|
|
367
|
+
return await self.exchange(attempt, callback, cancel)
|
|
368
|
+
future = asyncio.get_running_loop().create_future()
|
|
369
|
+
handlers = set()
|
|
370
|
+
|
|
371
|
+
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
372
|
+
task = asyncio.current_task()
|
|
373
|
+
assert task is not None
|
|
374
|
+
handlers.add(task)
|
|
375
|
+
try:
|
|
376
|
+
async with asyncio.timeout(5):
|
|
377
|
+
raw = await reader.readuntil(b"\r\n\r\n")
|
|
378
|
+
line = raw.split(b"\r\n", 1)[0].decode("ascii")
|
|
379
|
+
method, target, _ = line.split(" ", 2)
|
|
380
|
+
if method != "GET" or not target.startswith("/") or target.startswith("//"):
|
|
381
|
+
raise ValueError
|
|
382
|
+
callback = f"{uri.scheme}://{uri.netloc}{target}"
|
|
383
|
+
self._callback(attempt, callback)
|
|
384
|
+
if not future.done():
|
|
385
|
+
future.set_result(callback)
|
|
386
|
+
response = b"Login received. You may close this window."
|
|
387
|
+
writer.write(
|
|
388
|
+
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\nContent-Length: "
|
|
389
|
+
+ str(len(response)).encode()
|
|
390
|
+
+ b"\r\n\r\n"
|
|
391
|
+
+ response
|
|
392
|
+
)
|
|
393
|
+
await writer.drain()
|
|
394
|
+
except (
|
|
395
|
+
ValueError,
|
|
396
|
+
ConfigurationError,
|
|
397
|
+
TimeoutError,
|
|
398
|
+
asyncio.IncompleteReadError,
|
|
399
|
+
asyncio.LimitOverrunError,
|
|
400
|
+
):
|
|
401
|
+
if attempt.consumed and not future.done():
|
|
402
|
+
future.set_exception(
|
|
403
|
+
ConfigurationError("OAuth authorization was declined or failed")
|
|
404
|
+
)
|
|
405
|
+
writer.write(
|
|
406
|
+
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
|
407
|
+
)
|
|
408
|
+
finally:
|
|
409
|
+
writer.close()
|
|
410
|
+
await writer.wait_closed()
|
|
411
|
+
handlers.discard(task)
|
|
412
|
+
|
|
413
|
+
server = None
|
|
414
|
+
try:
|
|
415
|
+
try:
|
|
416
|
+
server = await asyncio.start_server(handle, "127.0.0.1", uri.port, limit=16384)
|
|
417
|
+
except OSError:
|
|
418
|
+
if on_prompt is None:
|
|
419
|
+
raise ConfigurationError(
|
|
420
|
+
"OAuth loopback port unavailable; pass on_prompt for manual callback"
|
|
421
|
+
) from None
|
|
422
|
+
await invoke(on_auth_url, attempt.authorize_url)
|
|
423
|
+
async with asyncio.timeout(timeout):
|
|
424
|
+
callback = await cancellable(
|
|
425
|
+
future if server else invoke(on_prompt, attempt.redirect_uri), cancel
|
|
426
|
+
)
|
|
427
|
+
return await self.exchange(attempt, callback, cancel)
|
|
428
|
+
finally:
|
|
429
|
+
if server:
|
|
430
|
+
server.close()
|
|
431
|
+
await server.wait_closed()
|
|
432
|
+
for task in list(handlers):
|
|
433
|
+
task.cancel()
|
|
434
|
+
await asyncio.gather(*handlers, return_exceptions=True)
|
|
435
|
+
if not future.done():
|
|
436
|
+
future.cancel()
|
|
437
|
+
|
|
438
|
+
async def device_login(
|
|
439
|
+
self,
|
|
440
|
+
on_device_code: Callable[[dict[str, Any]], Any],
|
|
441
|
+
*,
|
|
442
|
+
cancel: CancelToken | None = None,
|
|
443
|
+
timeout: float = 900,
|
|
444
|
+
) -> OAuthCredential:
|
|
445
|
+
if self.provider != "openai-codex":
|
|
446
|
+
raise ConfigurationError("Device login is available for Codex only")
|
|
447
|
+
cancel = cancel or CancelToken()
|
|
448
|
+
base = "https://auth.openai.com/api/accounts/deviceauth/"
|
|
449
|
+
device = await self.transport.request_json(
|
|
450
|
+
base + "usercode", {"client_id": _SETTINGS[self.provider][2]}, {}, cancel
|
|
451
|
+
)
|
|
452
|
+
interval = max(float(device.get("interval", 5)), 1)
|
|
453
|
+
await invoke(
|
|
454
|
+
on_device_code,
|
|
455
|
+
{
|
|
456
|
+
"verification_uri": "https://auth.openai.com/codex/device",
|
|
457
|
+
"user_code": device["user_code"],
|
|
458
|
+
"interval": interval,
|
|
459
|
+
"expires_in": timeout,
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
async with asyncio.timeout(timeout):
|
|
463
|
+
while True:
|
|
464
|
+
await cancellable(asyncio.sleep(interval), cancel)
|
|
465
|
+
try:
|
|
466
|
+
data = await self.transport.request_json(
|
|
467
|
+
base + "token",
|
|
468
|
+
{
|
|
469
|
+
"device_auth_id": device["device_auth_id"],
|
|
470
|
+
"user_code": device["user_code"],
|
|
471
|
+
},
|
|
472
|
+
{},
|
|
473
|
+
cancel,
|
|
474
|
+
)
|
|
475
|
+
except ProviderHTTPError as exc:
|
|
476
|
+
if exc.status in {403, 404}:
|
|
477
|
+
continue
|
|
478
|
+
raise
|
|
479
|
+
if data.get("error") in {
|
|
480
|
+
"authorization_pending",
|
|
481
|
+
"deviceauth_authorization_pending",
|
|
482
|
+
}:
|
|
483
|
+
continue
|
|
484
|
+
if data.get("error") == "slow_down":
|
|
485
|
+
interval += 5
|
|
486
|
+
continue
|
|
487
|
+
if not data.get("authorization_code") or not data.get("code_verifier"):
|
|
488
|
+
raise ProviderProtocolError("Invalid device authorization response")
|
|
489
|
+
token = await self._token(
|
|
490
|
+
{
|
|
491
|
+
"grant_type": "authorization_code",
|
|
492
|
+
"client_id": _SETTINGS[self.provider][2],
|
|
493
|
+
"code": data["authorization_code"],
|
|
494
|
+
"code_verifier": data["code_verifier"],
|
|
495
|
+
"redirect_uri": "https://auth.openai.com/deviceauth/callback",
|
|
496
|
+
},
|
|
497
|
+
cancel,
|
|
498
|
+
)
|
|
499
|
+
return self._credential(token, _SETTINGS[self.provider][2])
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class RefreshingCredentials:
|
|
503
|
+
"""Serialize refresh-token rotation; persist receives an immutable new record."""
|
|
504
|
+
|
|
505
|
+
def __init__(
|
|
506
|
+
self,
|
|
507
|
+
credential: OAuthCredential,
|
|
508
|
+
*,
|
|
509
|
+
client: OAuthClient | None = None,
|
|
510
|
+
persist: Callable[[OAuthCredential], Any] | None = None,
|
|
511
|
+
margin: float = 300,
|
|
512
|
+
) -> None:
|
|
513
|
+
self.credential = credential
|
|
514
|
+
self.client = client or OAuthClient(credential.provider)
|
|
515
|
+
self.persist = persist
|
|
516
|
+
self.margin = margin
|
|
517
|
+
self._lock = asyncio.Lock()
|
|
518
|
+
self._dirty = False
|
|
519
|
+
|
|
520
|
+
async def get(self, cancel: CancelToken | None = None) -> OAuthCredential:
|
|
521
|
+
cancel = cancel or CancelToken()
|
|
522
|
+
acquisition = asyncio.create_task(self._lock.acquire())
|
|
523
|
+
try:
|
|
524
|
+
await cancellable(acquisition, cancel)
|
|
525
|
+
cancel.raise_if_cancelled()
|
|
526
|
+
if self._dirty:
|
|
527
|
+
await invoke(self.persist, self.credential)
|
|
528
|
+
self._dirty = False
|
|
529
|
+
if self.credential.expires_at <= time.time() + self.margin:
|
|
530
|
+
self.credential = await self.client.refresh(self.credential, cancel)
|
|
531
|
+
self._dirty = True
|
|
532
|
+
await invoke(self.persist, self.credential)
|
|
533
|
+
self._dirty = False
|
|
534
|
+
return self.credential
|
|
535
|
+
finally:
|
|
536
|
+
if (
|
|
537
|
+
acquisition.done()
|
|
538
|
+
and not acquisition.cancelled()
|
|
539
|
+
and acquisition.exception() is None
|
|
540
|
+
and acquisition.result()
|
|
541
|
+
):
|
|
542
|
+
self._lock.release()
|