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,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()
@@ -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)