pi-ai-python 1.3.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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,321 @@
1
+ """OpenAI Responses API token sharing through Sign in with ChatGPT.
2
+
3
+ This public-client flow uses no client secret and sends the resulting user
4
+ access token directly to api.openai.com.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import errno
11
+ import math
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from ...abort import AbortController, AbortSignal, abort_scope
17
+ from ...utils.misc import get_provider_env_value
18
+ from ...utils.oauth_page import oauth_error_html, oauth_success_html
19
+ from ..types import AuthEvent, AuthPrompt, Credential, LoginOptions, ModelAuth, OAuthAuth
20
+ from ..types import ProviderAuthInteraction as Interaction
21
+ from . import _common
22
+ from ._common import OAuthError, ParsedUrl, form_encode, parse_url
23
+ from .callback_server import LoopbackHttpServer
24
+ from .pkce import generate_pkce
25
+
26
+ __all__ = ["OpenAIChatGPTOAuth", "openai_chatgpt_oauth"]
27
+
28
+ # every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
29
+ DYNAMIC_CLIENT_ID = "dynamic_agent_client"
30
+ AGENT_NAME_HINT = "Pi"
31
+ UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
32
+ AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize"
33
+ TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token"
34
+ RESOURCE = "https://api.openai.com/v1"
35
+ CALLBACK_PORT = 1455
36
+ CALLBACK_PATH = "/auth/callback"
37
+ REDIRECT_URI = f"http://127.0.0.1:{CALLBACK_PORT}{CALLBACK_PATH}"
38
+ DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct"
39
+ SCOPE = f"openid profile email offline_access resource.invoke {DIRECT_TOKEN_SCOPE}"
40
+ # Refresh this long before the real expiry so a request never starts with a token about to expire.
41
+ EXPIRY_MARGIN_MS = 3 * 60 * 1000
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class _AuthorizationResult:
46
+ code: str
47
+ client_id: str
48
+
49
+
50
+ def _callback_host() -> str:
51
+ return get_provider_env_value("PI_OAUTH_CALLBACK_HOST") or "127.0.0.1"
52
+
53
+
54
+ def _authorization_result_from_callback(url: ParsedUrl, expected_state: str) -> _AuthorizationResult:
55
+ code = url.param("code")
56
+ if not code:
57
+ raise OAuthError("Missing authorization code")
58
+ state = url.param("state")
59
+ if not state:
60
+ raise OAuthError("Missing OAuth state")
61
+ if state != expected_state:
62
+ raise OAuthError("OAuth state mismatch")
63
+ client_id = (url.param("client_id") or "").strip()
64
+ if not client_id:
65
+ raise OAuthError("OpenAI OAuth registration callback did not contain an issued client ID")
66
+ return _AuthorizationResult(code, client_id)
67
+
68
+
69
+ def _authorization_result_from_manual_input(value: str, expected_state: str) -> _AuthorizationResult:
70
+ url = parse_url(value.strip())
71
+ if url is None:
72
+ raise OAuthError("Paste the full callback URL from the browser")
73
+ expected = parse_url(REDIRECT_URI)
74
+ assert expected is not None
75
+ if url.origin != expected.origin or url.pathname != expected.pathname:
76
+ raise OAuthError(f"The pasted callback URL must start with {REDIRECT_URI}")
77
+ error = url.param("error")
78
+ if error:
79
+ raise OAuthError(f"ChatGPT authorization failed: {error}")
80
+ return _authorization_result_from_callback(url, expected_state)
81
+
82
+
83
+ class _CallbackServer:
84
+ def __init__(self, expected_state: str) -> None:
85
+ loop = asyncio.get_running_loop()
86
+ self.result: asyncio.Future[_AuthorizationResult] = loop.create_future()
87
+ self.result.add_done_callback(lambda future: future.cancelled() or future.exception())
88
+ self._expected_state = expected_state
89
+ self.server = LoopbackHttpServer(self._handle)
90
+
91
+ def _reject(self, error: BaseException) -> None:
92
+ if not self.result.done():
93
+ self.result.set_exception(error)
94
+
95
+ async def _handle(self, method: str, url: ParsedUrl | None) -> tuple[int, str]:
96
+ if url is None:
97
+ return 500, oauth_error_html("Internal error while processing the callback.")
98
+ if url.pathname != CALLBACK_PATH:
99
+ return 404, oauth_error_html("Callback route not found.")
100
+
101
+ error = url.param("error")
102
+ if error:
103
+ self._reject(OAuthError(f"ChatGPT authorization failed: {error}"))
104
+ return 400, oauth_error_html("ChatGPT was not connected.", f"Error: {error}")
105
+
106
+ try:
107
+ result = _authorization_result_from_callback(url, self._expected_state)
108
+ except OAuthError as failure:
109
+ return 400, oauth_error_html(str(failure) or "Invalid callback")
110
+
111
+ if not self.result.done():
112
+ self.result.set_result(result)
113
+ return 200, oauth_success_html("ChatGPT authentication completed. You can close this window.")
114
+
115
+
116
+ async def _start_callback_server(expected_state: str) -> _CallbackServer:
117
+ callback = _CallbackServer(expected_state)
118
+ await callback.server.listen(_callback_host(), CALLBACK_PORT)
119
+ return callback
120
+
121
+
122
+ async def _request_token(body: str, signal: AbortSignal) -> dict[str, Any]:
123
+ response = await _common.fetch(
124
+ TOKEN_URL,
125
+ method="POST",
126
+ headers={"accept": "application/json", "content-type": "application/x-www-form-urlencoded"},
127
+ body=body,
128
+ signal=signal,
129
+ )
130
+ if not response.is_success:
131
+ response_body = response.text
132
+ raise OAuthError(
133
+ f"OpenAI OAuth token request failed ({response.status_code}): "
134
+ f"{response_body or _common.status_text(response)}"
135
+ )
136
+ data = _common.parse_json(response)
137
+ if not isinstance(data, dict):
138
+ raise OAuthError("OpenAI OAuth token response must be an object")
139
+ return data
140
+
141
+
142
+ def _require_token_string(value: Any, field: str) -> str:
143
+ if not isinstance(value, str) or not value.strip():
144
+ raise OAuthError(f"OpenAI OAuth token response has invalid {field}")
145
+ return value
146
+
147
+
148
+ def _credential_from_token_response(token: dict[str, Any], client_id: str) -> Credential:
149
+ access = _require_token_string(token.get("access_token"), "access_token")
150
+ refresh = _require_token_string(token.get("refresh_token"), "refresh_token")
151
+ scope = _require_token_string(token.get("scope"), "scope")
152
+ expires_in = token.get("expires_in")
153
+ if not _common.is_number(expires_in) or not math.isfinite(expires_in) or expires_in <= 0:
154
+ raise OAuthError("OpenAI OAuth token response has invalid expires_in")
155
+ scopes = scope.split()
156
+ if DIRECT_TOKEN_SCOPE not in scopes:
157
+ raise OAuthError(f"OpenAI OAuth grant did not include {DIRECT_TOKEN_SCOPE}")
158
+ return {
159
+ "type": "oauth",
160
+ "access": access,
161
+ "refresh": refresh,
162
+ "expires": _common.now_ms() + _common.expires_in_ms(expires_in) - EXPIRY_MARGIN_MS,
163
+ "clientId": client_id,
164
+ "scopes": scopes,
165
+ }
166
+
167
+
168
+ async def _exchange_authorization_code(code: str, verifier: str, client_id: str, signal: AbortSignal) -> Credential:
169
+ token = await _request_token(
170
+ form_encode(
171
+ {
172
+ "grant_type": "authorization_code",
173
+ "client_id": client_id,
174
+ "code": code,
175
+ "code_verifier": verifier,
176
+ "redirect_uri": REDIRECT_URI,
177
+ "resource": RESOURCE,
178
+ }
179
+ ),
180
+ signal,
181
+ )
182
+ # Pi does not use the ID token to identify the user or read profile data.
183
+ # Keep the presence check as part of the token-response contract.
184
+ id_token = token.get("id_token")
185
+ if not isinstance(id_token, str) or not id_token.strip():
186
+ raise OAuthError("OpenAI OAuth token response did not contain an ID token")
187
+ return _credential_from_token_response(token, client_id)
188
+
189
+
190
+ async def _refresh_access_token(credential: Credential, signal: AbortSignal) -> Credential:
191
+ client_id = credential.get("clientId")
192
+ if not isinstance(client_id, str) or not client_id.strip():
193
+ raise OAuthError("Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT")
194
+ token = await _request_token(
195
+ form_encode(
196
+ {
197
+ "grant_type": "refresh_token",
198
+ "client_id": client_id,
199
+ "refresh_token": credential["refresh"],
200
+ "resource": RESOURCE,
201
+ }
202
+ ),
203
+ signal,
204
+ )
205
+ return _credential_from_token_response(token, client_id)
206
+
207
+
208
+ def _agent_host_id(device_id: str | None) -> str:
209
+ """OpenAI identifies each installation ("agent host") by a stable URI such as ``urn:uuid:<uuid>``."""
210
+ if not device_id or not UUID_PATTERN.match(device_id):
211
+ raise OAuthError("Sign in with ChatGPT requires a device ID (UUID) for this installation")
212
+ return f"urn:uuid:{device_id.lower()}"
213
+
214
+
215
+ async def _login_openai_chatgpt(interaction: Interaction, options: LoginOptions | None = None) -> Credential:
216
+ get_device_id = options.get_device_id if options is not None else None
217
+ host_id = _agent_host_id(get_device_id() if get_device_id is not None else None)
218
+ verifier, challenge = generate_pkce()
219
+ state = _common.random_base64url(32)
220
+ nonce = _common.random_base64url(32)
221
+ # Without this server, the browser's callback would reach whatever else holds the port (another
222
+ # pending login or the Codex CLI), which rejects it as a state mismatch. Fail with a clear error instead.
223
+ try:
224
+ callback = await _start_callback_server(state)
225
+ except OSError as error:
226
+ if error.errno != errno.EADDRINUSE:
227
+ raise
228
+ raise OAuthError(
229
+ f"Port {CALLBACK_PORT} is in use, probably by an unfinished login in another pi session or by the "
230
+ "Codex CLI. Cancel that login and try again."
231
+ ) from error
232
+
233
+ agent_name = options.agent_name if options is not None else None
234
+ params = {
235
+ "client_id": DYNAMIC_CLIENT_ID,
236
+ "agent_name_hint": agent_name if agent_name is not None else AGENT_NAME_HINT,
237
+ "ext_agent_host_id": host_id,
238
+ "response_type": "code",
239
+ "redirect_uri": REDIRECT_URI,
240
+ "resource": RESOURCE,
241
+ "scope": SCOPE,
242
+ "state": state,
243
+ "code_challenge": challenge,
244
+ "code_challenge_method": "S256",
245
+ "nonce": nonce,
246
+ }
247
+ manual_abort = AbortController()
248
+ # The manual prompt's signal also aborts with the login (`AbortSignal.any` upstream).
249
+ remove_abort_forward = interaction.signal.add_listener(lambda: manual_abort.abort(interaction.signal.reason))
250
+ manual_task: asyncio.Future[_AuthorizationResult] | None = None
251
+ try:
252
+ interaction.notify(
253
+ AuthEvent(
254
+ type="auth_url",
255
+ url=f"{AUTHORIZE_URL}?{form_encode(params)}",
256
+ instructions=(
257
+ "Complete sign-in in your browser. If the callback does not complete, "
258
+ "paste the final redirect URL here."
259
+ ),
260
+ )
261
+ )
262
+
263
+ async def manual_code() -> _AuthorizationResult:
264
+ entered = await interaction.prompt(
265
+ AuthPrompt(
266
+ type="manual_code",
267
+ message="Complete login in your browser, or paste the final redirect URL here:",
268
+ placeholder=REDIRECT_URI,
269
+ signal=manual_abort.signal,
270
+ )
271
+ )
272
+ return _authorization_result_from_manual_input(entered, state)
273
+
274
+ manual_task = asyncio.ensure_future(manual_code())
275
+ manual_task.add_done_callback(_consume_task)
276
+ async with abort_scope(interaction.signal):
277
+ # Promise.race: the first side to settle wins, including with an error.
278
+ done, _ = await asyncio.wait({callback.result, manual_task}, return_when=asyncio.FIRST_COMPLETED)
279
+ winner = callback.result if callback.result in done else manual_task
280
+ result = winner.result()
281
+ interaction.notify(AuthEvent(type="progress", message="Exchanging authorization code for tokens..."))
282
+ return await _exchange_authorization_code(result.code, verifier, result.client_id, interaction.signal)
283
+ except Exception as error:
284
+ if interaction.signal.aborted:
285
+ raise _common.login_cancelled() from error
286
+ raise
287
+ finally:
288
+ remove_abort_forward()
289
+ manual_abort.abort()
290
+ if manual_task is not None and not manual_task.done():
291
+ manual_task.cancel()
292
+ callback.server.close()
293
+ # close() only stops accepting new connections. Browsers open spare connections ahead of
294
+ # time, and one that has not sent a request yet stays open and attached to this server.
295
+ # A later login in the same process starts a new server with a new state, but the browser
296
+ # may send that login's callback over the spare connection. This server would then handle
297
+ # it and reject it with "OAuth state mismatch", and the new login would never see it.
298
+ callback.server.close_all_connections()
299
+
300
+
301
+ def _consume_task(task: asyncio.Future[Any]) -> None:
302
+ if not task.cancelled():
303
+ task.exception()
304
+
305
+
306
+ class OpenAIChatGPTOAuth(OAuthAuth):
307
+ name = "OpenAI (ChatGPT subscription)"
308
+ is_subscription = True
309
+ login_label = "Sign in with ChatGPT"
310
+
311
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
312
+ return await _login_openai_chatgpt(interaction, options)
313
+
314
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
315
+ return await _refresh_access_token(credential, signal)
316
+
317
+ async def to_auth(self, credential: Credential) -> ModelAuth:
318
+ return ModelAuth(api_key=credential["access"])
319
+
320
+
321
+ openai_chatgpt_oauth = OpenAIChatGPTOAuth()