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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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()
|