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,391 @@
|
|
|
1
|
+
"""OpenAI Codex (ChatGPT OAuth) flow: browser login with a loopback callback, or device code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ...abort import AbortSignal
|
|
14
|
+
from ...utils.misc import get_provider_env_value
|
|
15
|
+
from ..types import AuthEvent, AuthPrompt, AuthSelectOption, Credential, LoginOptions, ModelAuth, OAuthAuth
|
|
16
|
+
from ..types import ProviderAuthInteraction as Interaction
|
|
17
|
+
from . import _common
|
|
18
|
+
from ._common import OAuthError, form_encode, json_stringify, parse_authorization_input
|
|
19
|
+
from .callback_server import (
|
|
20
|
+
CallbackResult,
|
|
21
|
+
OAuthCallbackServer,
|
|
22
|
+
start_oauth_callback_server,
|
|
23
|
+
wait_for_callback_or_manual_input,
|
|
24
|
+
)
|
|
25
|
+
from .device_code import (
|
|
26
|
+
DeviceCodeComplete,
|
|
27
|
+
DeviceCodeFailed,
|
|
28
|
+
DeviceCodePending,
|
|
29
|
+
DeviceCodePollResult,
|
|
30
|
+
DeviceCodeSlowDown,
|
|
31
|
+
poll_oauth_device_code_flow,
|
|
32
|
+
)
|
|
33
|
+
from .pkce import generate_pkce
|
|
34
|
+
|
|
35
|
+
__all__ = ["OpenAICodexOAuth", "openai_codex_oauth"]
|
|
36
|
+
|
|
37
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
38
|
+
AUTH_BASE_URL = "https://auth.openai.com"
|
|
39
|
+
AUTHORIZE_URL = f"{AUTH_BASE_URL}/oauth/authorize"
|
|
40
|
+
TOKEN_URL = f"{AUTH_BASE_URL}/oauth/token"
|
|
41
|
+
REDIRECT_URI = "http://localhost:1455/auth/callback"
|
|
42
|
+
DEVICE_USER_CODE_URL = f"{AUTH_BASE_URL}/api/accounts/deviceauth/usercode"
|
|
43
|
+
DEVICE_TOKEN_URL = f"{AUTH_BASE_URL}/api/accounts/deviceauth/token"
|
|
44
|
+
DEVICE_VERIFICATION_URI = f"{AUTH_BASE_URL}/codex/device"
|
|
45
|
+
DEVICE_REDIRECT_URI = f"{AUTH_BASE_URL}/deviceauth/callback"
|
|
46
|
+
DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
|
|
47
|
+
OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"
|
|
48
|
+
OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"
|
|
49
|
+
SCOPE = "openid profile email offline_access"
|
|
50
|
+
JWT_CLAIM_PATH = "https://api.openai.com/auth"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class _OAuthToken:
|
|
55
|
+
access: str
|
|
56
|
+
refresh: str
|
|
57
|
+
expires: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class _DeviceAuthInfo:
|
|
62
|
+
device_auth_id: str
|
|
63
|
+
user_code: str
|
|
64
|
+
interval_seconds: float
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class _DeviceTokenSuccess:
|
|
69
|
+
authorization_code: str
|
|
70
|
+
code_verifier: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _callback_host() -> str:
|
|
74
|
+
return get_provider_env_value("PI_OAUTH_CALLBACK_HOST") or "127.0.0.1"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _decode_jwt(token: str) -> dict[str, Any] | None:
|
|
78
|
+
try:
|
|
79
|
+
parts = token.split(".")
|
|
80
|
+
if len(parts) != 3:
|
|
81
|
+
return None
|
|
82
|
+
payload = parts[1].replace("-", "+").replace("_", "/")
|
|
83
|
+
decoded = base64.b64decode(payload + "=" * (-len(payload) % 4))
|
|
84
|
+
value = json.loads(decoded)
|
|
85
|
+
return value if isinstance(value, dict) else None
|
|
86
|
+
except (ValueError, TypeError):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _fetch_with_login_cancellation(url: str, signal: AbortSignal, **kwargs: Any) -> httpx.Response:
|
|
91
|
+
try:
|
|
92
|
+
return await _common.fetch(url, signal=signal, **kwargs)
|
|
93
|
+
except Exception:
|
|
94
|
+
if signal.aborted:
|
|
95
|
+
raise _common.login_cancelled() from None
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _read_token_response(response: httpx.Response, operation: str) -> _OAuthToken:
|
|
100
|
+
if not response.is_success:
|
|
101
|
+
text = response.text
|
|
102
|
+
raise OAuthError(
|
|
103
|
+
f"OpenAI Codex token {operation} failed ({response.status_code}): {text or _common.status_text(response)}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
data = _common.parse_json(response)
|
|
107
|
+
record = data if isinstance(data, dict) else {}
|
|
108
|
+
if (
|
|
109
|
+
not record.get("access_token")
|
|
110
|
+
or not record.get("refresh_token")
|
|
111
|
+
or not _common.is_number(record.get("expires_in"))
|
|
112
|
+
):
|
|
113
|
+
raise OAuthError(f"OpenAI Codex token {operation} response missing fields: {json_stringify(data)}")
|
|
114
|
+
|
|
115
|
+
return _OAuthToken(
|
|
116
|
+
access=record["access_token"],
|
|
117
|
+
refresh=record["refresh_token"],
|
|
118
|
+
expires=_common.now_ms() + _common.expires_in_ms(record["expires_in"]),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _exchange_authorization_code(code: str, verifier: str, redirect_uri: str, signal: AbortSignal) -> _OAuthToken:
|
|
123
|
+
response = await _fetch_with_login_cancellation(
|
|
124
|
+
TOKEN_URL,
|
|
125
|
+
signal,
|
|
126
|
+
method="POST",
|
|
127
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
128
|
+
body=form_encode(
|
|
129
|
+
{
|
|
130
|
+
"grant_type": "authorization_code",
|
|
131
|
+
"client_id": CLIENT_ID,
|
|
132
|
+
"code": code,
|
|
133
|
+
"code_verifier": verifier,
|
|
134
|
+
"redirect_uri": redirect_uri,
|
|
135
|
+
}
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
return await _read_token_response(response, "exchange")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _refresh_access_token(refresh_token: str, signal: AbortSignal) -> _OAuthToken:
|
|
142
|
+
try:
|
|
143
|
+
response = await _common.fetch(
|
|
144
|
+
TOKEN_URL,
|
|
145
|
+
method="POST",
|
|
146
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
147
|
+
body=form_encode({"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CLIENT_ID}),
|
|
148
|
+
signal=signal,
|
|
149
|
+
)
|
|
150
|
+
except Exception as error:
|
|
151
|
+
raise OAuthError(f"OpenAI Codex token refresh error: {error}") from error
|
|
152
|
+
return await _read_token_response(response, "refresh")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _js_number(value: str) -> float:
|
|
156
|
+
"""``Number(string)``: blank is ``0``, unparsable is ``NaN``."""
|
|
157
|
+
text = value.strip()
|
|
158
|
+
if not text:
|
|
159
|
+
return 0.0
|
|
160
|
+
if "_" in text:
|
|
161
|
+
return math.nan
|
|
162
|
+
try:
|
|
163
|
+
return float(int(text, 0)) if text.lower().startswith(("0x", "0o", "0b")) else float(text)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return math.nan
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _plain_number(value: float) -> float:
|
|
169
|
+
return int(value) if isinstance(value, float) and value.is_integer() else value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _start_device_auth(signal: AbortSignal) -> _DeviceAuthInfo:
|
|
173
|
+
response = await _fetch_with_login_cancellation(
|
|
174
|
+
DEVICE_USER_CODE_URL,
|
|
175
|
+
signal,
|
|
176
|
+
method="POST",
|
|
177
|
+
headers={"Content-Type": "application/json"},
|
|
178
|
+
body=json_stringify({"client_id": CLIENT_ID}),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not response.is_success:
|
|
182
|
+
if response.status_code == 404:
|
|
183
|
+
raise OAuthError(
|
|
184
|
+
"OpenAI Codex device code login is not enabled for this server. "
|
|
185
|
+
"Use browser login or verify the server URL."
|
|
186
|
+
)
|
|
187
|
+
body = response.text
|
|
188
|
+
raise OAuthError(
|
|
189
|
+
f"OpenAI Codex device code request failed with status {response.status_code}{f': {body}' if body else ''}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
data = _common.parse_json(response)
|
|
193
|
+
record = data if isinstance(data, dict) else {}
|
|
194
|
+
interval = record.get("interval")
|
|
195
|
+
interval_seconds = _js_number(interval) if isinstance(interval, str) else interval
|
|
196
|
+
if (
|
|
197
|
+
not record.get("device_auth_id")
|
|
198
|
+
or not record.get("user_code")
|
|
199
|
+
or not _common.is_number(interval_seconds)
|
|
200
|
+
or not math.isfinite(interval_seconds)
|
|
201
|
+
or interval_seconds < 0
|
|
202
|
+
):
|
|
203
|
+
raise OAuthError(f"Invalid OpenAI Codex device code response: {json_stringify(data)}")
|
|
204
|
+
|
|
205
|
+
return _DeviceAuthInfo(record["device_auth_id"], record["user_code"], _plain_number(interval_seconds))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _poll_device_auth(device: _DeviceAuthInfo, signal: AbortSignal) -> _DeviceTokenSuccess:
|
|
209
|
+
async def poll() -> DeviceCodePollResult:
|
|
210
|
+
response = await _fetch_with_login_cancellation(
|
|
211
|
+
DEVICE_TOKEN_URL,
|
|
212
|
+
signal,
|
|
213
|
+
method="POST",
|
|
214
|
+
headers={"Content-Type": "application/json"},
|
|
215
|
+
body=json_stringify({"device_auth_id": device.device_auth_id, "user_code": device.user_code}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if response.is_success:
|
|
219
|
+
data = _common.parse_json(response)
|
|
220
|
+
record = data if isinstance(data, dict) else {}
|
|
221
|
+
if not record.get("authorization_code") or not record.get("code_verifier"):
|
|
222
|
+
return DeviceCodeFailed(f"Invalid OpenAI Codex device auth token response: {json_stringify(data)}")
|
|
223
|
+
return DeviceCodeComplete(_DeviceTokenSuccess(record["authorization_code"], record["code_verifier"]))
|
|
224
|
+
|
|
225
|
+
if response.status_code in (403, 404):
|
|
226
|
+
return DeviceCodePending()
|
|
227
|
+
|
|
228
|
+
body = response.text
|
|
229
|
+
error_code: Any = None
|
|
230
|
+
try:
|
|
231
|
+
parsed = json.loads(body)
|
|
232
|
+
error = parsed.get("error") if isinstance(parsed, dict) else None
|
|
233
|
+
error_code = error.get("code") if isinstance(error, dict) else error
|
|
234
|
+
except ValueError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
if error_code == "deviceauth_authorization_pending":
|
|
238
|
+
return DeviceCodePending()
|
|
239
|
+
if error_code == "slow_down":
|
|
240
|
+
return DeviceCodeSlowDown()
|
|
241
|
+
|
|
242
|
+
return DeviceCodeFailed(
|
|
243
|
+
f"OpenAI Codex device auth failed with status {response.status_code}{f': {body}' if body else ''}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return await poll_oauth_device_code_flow(
|
|
247
|
+
poll=poll,
|
|
248
|
+
signal=signal,
|
|
249
|
+
interval_seconds=device.interval_seconds,
|
|
250
|
+
expires_in_seconds=DEVICE_CODE_TIMEOUT_SECONDS,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _create_authorization_flow(originator: str = "pi") -> tuple[str, str, str]:
|
|
255
|
+
verifier, challenge = generate_pkce()
|
|
256
|
+
state = _common.random_hex(16)
|
|
257
|
+
params = {
|
|
258
|
+
"response_type": "code",
|
|
259
|
+
"client_id": CLIENT_ID,
|
|
260
|
+
"redirect_uri": REDIRECT_URI,
|
|
261
|
+
"scope": SCOPE,
|
|
262
|
+
"code_challenge": challenge,
|
|
263
|
+
"code_challenge_method": "S256",
|
|
264
|
+
"state": state,
|
|
265
|
+
"id_token_add_organizations": "true",
|
|
266
|
+
"codex_cli_simplified_flow": "true",
|
|
267
|
+
"originator": originator,
|
|
268
|
+
}
|
|
269
|
+
return verifier, state, f"{AUTHORIZE_URL}?{form_encode(params)}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_account_id(access_token: str) -> str | None:
|
|
273
|
+
"""The ChatGPT account id claim of an OpenAI access token."""
|
|
274
|
+
payload = _decode_jwt(access_token)
|
|
275
|
+
auth = payload.get(JWT_CLAIM_PATH) if payload else None
|
|
276
|
+
account_id = auth.get("chatgpt_account_id") if isinstance(auth, dict) else None
|
|
277
|
+
return account_id if isinstance(account_id, str) and account_id else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _credentials_from_token(token: _OAuthToken) -> Credential:
|
|
281
|
+
account_id = get_account_id(token.access)
|
|
282
|
+
if not account_id:
|
|
283
|
+
raise OAuthError("Failed to extract accountId from token")
|
|
284
|
+
return {
|
|
285
|
+
"type": "oauth",
|
|
286
|
+
"access": token.access,
|
|
287
|
+
"refresh": token.refresh,
|
|
288
|
+
"expires": token.expires,
|
|
289
|
+
"accountId": account_id,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _exchange_for_credentials(code: str, verifier: str, redirect_uri: str, signal: AbortSignal) -> Credential:
|
|
294
|
+
return _credentials_from_token(await _exchange_authorization_code(code, verifier, redirect_uri, signal))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def _login_device_code(interaction: Interaction) -> Credential:
|
|
298
|
+
device = await _start_device_auth(interaction.signal)
|
|
299
|
+
interaction.notify(
|
|
300
|
+
AuthEvent(
|
|
301
|
+
type="device_code",
|
|
302
|
+
user_code=device.user_code,
|
|
303
|
+
verification_uri=DEVICE_VERIFICATION_URI,
|
|
304
|
+
interval_seconds=device.interval_seconds,
|
|
305
|
+
expires_in_seconds=DEVICE_CODE_TIMEOUT_SECONDS,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
code = await _poll_device_auth(device, interaction.signal)
|
|
309
|
+
return await _exchange_for_credentials(
|
|
310
|
+
code.authorization_code, code.code_verifier, DEVICE_REDIRECT_URI, interaction.signal
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def _login_browser(interaction: Interaction, options: LoginOptions | None) -> Credential:
|
|
315
|
+
agent_name = options.agent_name if options is not None else None
|
|
316
|
+
verifier, state, url = _create_authorization_flow(agent_name if agent_name is not None else "pi")
|
|
317
|
+
|
|
318
|
+
async def identity(code: str) -> str:
|
|
319
|
+
return code
|
|
320
|
+
|
|
321
|
+
# Port 1455 is shared with the Codex CLI; when it is taken, fall back to the pasted redirect URL.
|
|
322
|
+
callback: OAuthCallbackServer[str] | None
|
|
323
|
+
try:
|
|
324
|
+
callback = await start_oauth_callback_server(
|
|
325
|
+
provider_name="OpenAI",
|
|
326
|
+
host=_callback_host(),
|
|
327
|
+
port=1455,
|
|
328
|
+
path="/auth/callback",
|
|
329
|
+
state=state,
|
|
330
|
+
complete=identity,
|
|
331
|
+
signal=interaction.signal,
|
|
332
|
+
)
|
|
333
|
+
except Exception:
|
|
334
|
+
callback = None
|
|
335
|
+
|
|
336
|
+
interaction.notify(
|
|
337
|
+
AuthEvent(type="auth_url", url=url, instructions="A browser window should open. Complete login to finish.")
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
result = await wait_for_callback_or_manual_input(
|
|
342
|
+
interaction,
|
|
343
|
+
callback,
|
|
344
|
+
message="Complete login in your browser, or paste the authorization code / redirect URL here:",
|
|
345
|
+
placeholder=REDIRECT_URI,
|
|
346
|
+
)
|
|
347
|
+
code: str | None
|
|
348
|
+
if isinstance(result, CallbackResult):
|
|
349
|
+
code = result.value
|
|
350
|
+
else:
|
|
351
|
+
code, parsed_state = parse_authorization_input(result.input)
|
|
352
|
+
if parsed_state and parsed_state != state:
|
|
353
|
+
raise OAuthError("State mismatch")
|
|
354
|
+
|
|
355
|
+
if not code:
|
|
356
|
+
raise OAuthError("Missing authorization code")
|
|
357
|
+
return await _exchange_for_credentials(code, verifier, REDIRECT_URI, interaction.signal)
|
|
358
|
+
finally:
|
|
359
|
+
if callback is not None:
|
|
360
|
+
callback.close()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class OpenAICodexOAuth(OAuthAuth):
|
|
364
|
+
name = "OpenAI (ChatGPT Plus/Pro)"
|
|
365
|
+
is_subscription = True
|
|
366
|
+
|
|
367
|
+
async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
|
|
368
|
+
method = await interaction.prompt(
|
|
369
|
+
AuthPrompt(
|
|
370
|
+
type="select",
|
|
371
|
+
message="Select OpenAI Codex login method:",
|
|
372
|
+
options=[
|
|
373
|
+
AuthSelectOption(OPENAI_CODEX_BROWSER_LOGIN_METHOD, "Browser login (default)"),
|
|
374
|
+
AuthSelectOption(OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, "Device code login (headless)"),
|
|
375
|
+
],
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
if method == OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD:
|
|
379
|
+
return await _login_device_code(interaction)
|
|
380
|
+
if method != OPENAI_CODEX_BROWSER_LOGIN_METHOD:
|
|
381
|
+
raise OAuthError(f"Unknown OpenAI Codex login method: {method}")
|
|
382
|
+
return await _login_browser(interaction, options)
|
|
383
|
+
|
|
384
|
+
async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
|
|
385
|
+
return _credentials_from_token(await _refresh_access_token(credential["refresh"], signal))
|
|
386
|
+
|
|
387
|
+
async def to_auth(self, credential: Credential) -> ModelAuth:
|
|
388
|
+
return ModelAuth(api_key=credential["access"])
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
openai_codex_oauth = OpenAICodexOAuth()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""OpenRouter OAuth PKCE flow.
|
|
2
|
+
|
|
3
|
+
OpenRouter exchanges an authorization code for a permanent, user-controlled API
|
|
4
|
+
key rather than an expiring access/refresh token pair. The callback is handled by
|
|
5
|
+
a one-shot loopback server on an ephemeral port, raced against a manual prompt so
|
|
6
|
+
remote/headless sessions can paste the redirect URL when the browser cannot reach
|
|
7
|
+
the loopback server.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ...abort import AbortSignal
|
|
16
|
+
from ...utils.misc import get_provider_env_value
|
|
17
|
+
from ..types import AuthEvent, Credential, LoginOptions, ModelAuth, OAuthAuth
|
|
18
|
+
from ..types import ProviderAuthInteraction as Interaction
|
|
19
|
+
from . import _common
|
|
20
|
+
from ._common import MAX_SAFE_INTEGER, OAuthError, form_encode, json_stringify, parse_search_params, parse_url
|
|
21
|
+
from .callback_server import CallbackResult, start_oauth_callback_server, wait_for_callback_or_manual_input
|
|
22
|
+
from .pkce import generate_pkce
|
|
23
|
+
|
|
24
|
+
__all__ = ["OpenRouterOAuth", "openrouter_oauth"]
|
|
25
|
+
|
|
26
|
+
AUTHORIZE_URL = "https://openrouter.ai/auth"
|
|
27
|
+
TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys"
|
|
28
|
+
LOGIN_TIMEOUT_MS = 5 * 60 * 1000
|
|
29
|
+
TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _callback_host() -> str:
|
|
33
|
+
return get_provider_env_value("PI_OAUTH_CALLBACK_HOST") or "127.0.0.1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_authorization_input(value: str) -> str | None:
|
|
37
|
+
value = value.strip()
|
|
38
|
+
if not value:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
url = parse_url(value)
|
|
42
|
+
if url is not None:
|
|
43
|
+
return url.param("code")
|
|
44
|
+
|
|
45
|
+
if "code=" in value:
|
|
46
|
+
for key, param in parse_search_params(value):
|
|
47
|
+
if key == "code":
|
|
48
|
+
return param
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _error_detail(body: dict[str, Any]) -> str | None:
|
|
55
|
+
if isinstance(body.get("error_description"), str):
|
|
56
|
+
return body["error_description"]
|
|
57
|
+
if isinstance(body.get("message"), str):
|
|
58
|
+
return body["message"]
|
|
59
|
+
if isinstance(body.get("error"), str):
|
|
60
|
+
return body["error"]
|
|
61
|
+
error = body.get("error")
|
|
62
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
63
|
+
return error["message"]
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _exchange_authorization_code(code: str, verifier: str, signal: AbortSignal) -> Credential:
|
|
68
|
+
if signal.aborted:
|
|
69
|
+
raise _common.login_cancelled()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
response = await _common.fetch(
|
|
73
|
+
TOKEN_URL,
|
|
74
|
+
method="POST",
|
|
75
|
+
headers={"accept": "application/json", "content-type": "application/json"},
|
|
76
|
+
body=json_stringify({"code": code, "code_verifier": verifier, "code_challenge_method": "S256"}),
|
|
77
|
+
signal=signal,
|
|
78
|
+
timeout_ms=TOKEN_EXCHANGE_TIMEOUT_MS,
|
|
79
|
+
)
|
|
80
|
+
except Exception as error:
|
|
81
|
+
if signal.aborted:
|
|
82
|
+
raise _common.login_cancelled() from error
|
|
83
|
+
if isinstance(error, TimeoutError):
|
|
84
|
+
raise OAuthError("OpenRouter OAuth token exchange timed out") from error
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
body: dict[str, Any] = {}
|
|
88
|
+
try:
|
|
89
|
+
parsed = _common.parse_json(response)
|
|
90
|
+
if isinstance(parsed, dict):
|
|
91
|
+
body = parsed
|
|
92
|
+
except ValueError:
|
|
93
|
+
if response.is_success:
|
|
94
|
+
raise OAuthError("OpenRouter OAuth returned invalid JSON") from None
|
|
95
|
+
|
|
96
|
+
if not response.is_success:
|
|
97
|
+
detail = _error_detail(body)
|
|
98
|
+
raise OAuthError(
|
|
99
|
+
f"OpenRouter OAuth key exchange failed (HTTP {response.status_code}){f': {detail}' if detail else ''}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
key = body.get("key")
|
|
103
|
+
if not isinstance(key, str) or not key:
|
|
104
|
+
raise OAuthError('OpenRouter OAuth response carries no "key"')
|
|
105
|
+
|
|
106
|
+
return {"type": "oauth", "access": key, "refresh": "", "expires": MAX_SAFE_INTEGER}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _login_openrouter(interaction: Interaction) -> Credential:
|
|
110
|
+
verifier, challenge = generate_pkce()
|
|
111
|
+
|
|
112
|
+
async def complete(code: str) -> Credential:
|
|
113
|
+
return await _exchange_authorization_code(code, verifier, interaction.signal)
|
|
114
|
+
|
|
115
|
+
# OpenRouter sends no `state`; the random path keeps stray requests from completing the sign-in.
|
|
116
|
+
callback = await start_oauth_callback_server(
|
|
117
|
+
provider_name="OpenRouter",
|
|
118
|
+
host=_callback_host(),
|
|
119
|
+
port=0,
|
|
120
|
+
path=f"/oauth/callback/{uuid.uuid4()}",
|
|
121
|
+
complete=complete,
|
|
122
|
+
signal=interaction.signal,
|
|
123
|
+
timeout_ms=LOGIN_TIMEOUT_MS,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
params = {"callback_url": callback.redirect_uri, "code_challenge": challenge, "code_challenge_method": "S256"}
|
|
128
|
+
interaction.notify(
|
|
129
|
+
AuthEvent(type="progress", message=f"Listening for OpenRouter OAuth callback on {callback.redirect_uri}")
|
|
130
|
+
)
|
|
131
|
+
interaction.notify(
|
|
132
|
+
AuthEvent(
|
|
133
|
+
type="auth_url",
|
|
134
|
+
url=f"{AUTHORIZE_URL}?{form_encode(params)}",
|
|
135
|
+
instructions=(
|
|
136
|
+
"Complete sign-in in your browser. If the browser is on another machine, "
|
|
137
|
+
"paste the final redirect URL here."
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
result = await wait_for_callback_or_manual_input(
|
|
143
|
+
interaction,
|
|
144
|
+
callback,
|
|
145
|
+
message="Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
|
|
146
|
+
placeholder=callback.redirect_uri,
|
|
147
|
+
)
|
|
148
|
+
if isinstance(result, CallbackResult):
|
|
149
|
+
return result.value
|
|
150
|
+
code = _parse_authorization_input(result.input)
|
|
151
|
+
if not code:
|
|
152
|
+
raise OAuthError("Missing authorization code")
|
|
153
|
+
interaction.notify(AuthEvent(type="progress", message="Exchanging authorization code for an API key..."))
|
|
154
|
+
return await _exchange_authorization_code(code, verifier, interaction.signal)
|
|
155
|
+
finally:
|
|
156
|
+
callback.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class OpenRouterOAuth(OAuthAuth):
|
|
160
|
+
name = "OpenRouter OAuth"
|
|
161
|
+
login_label = "Sign in with OpenRouter"
|
|
162
|
+
|
|
163
|
+
async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
|
|
164
|
+
return await _login_openrouter(interaction)
|
|
165
|
+
|
|
166
|
+
async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
|
|
167
|
+
return credential
|
|
168
|
+
|
|
169
|
+
async def to_auth(self, credential: Credential) -> ModelAuth:
|
|
170
|
+
return ModelAuth(api_key=credential["access"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
openrouter_oauth = OpenRouterOAuth()
|
pi_ai/auth/oauth/pkce.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""PKCE (RFC 7636) code verifier and S256 challenge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
from ._common import base64url_encode
|
|
10
|
+
|
|
11
|
+
__all__ = ["PKCE", "generate_pkce"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PKCE(NamedTuple):
|
|
15
|
+
verifier: str
|
|
16
|
+
challenge: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_pkce() -> PKCE:
|
|
20
|
+
"""Generate a random code verifier and its SHA-256 challenge (both base64url, unpadded)."""
|
|
21
|
+
verifier = base64url_encode(secrets.token_bytes(32))
|
|
22
|
+
challenge = base64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
23
|
+
return PKCE(verifier, challenge)
|