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,283 @@
1
+ """Anthropic OAuth flow (Claude Pro/Max).
2
+
3
+ Browser login runs a loopback callback server; copy-code login is for headless use.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import json
10
+ import traceback
11
+ from typing import Any
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 .pkce import generate_pkce
26
+
27
+ __all__ = ["AnthropicOAuth", "anthropic_oauth"]
28
+
29
+ CLIENT_ID = base64.b64decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl").decode()
30
+ AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
31
+ TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
32
+ # Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port,
33
+ # so login falls back to a free port when this one cannot be bound (#10571).
34
+ CALLBACK_PORT = 53692
35
+ CALLBACK_PATH = "/callback"
36
+ REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}"
37
+ COPY_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"
38
+ ANTHROPIC_BROWSER_LOGIN_METHOD = "browser"
39
+ ANTHROPIC_COPY_CODE_LOGIN_METHOD = "copy_code"
40
+ SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
41
+ _EXPIRY_SKEW_MS = 5 * 60 * 1000
42
+ _REQUEST_TIMEOUT_MS = 30_000
43
+
44
+
45
+ def _callback_host() -> str:
46
+ return get_provider_env_value("PI_OAUTH_CALLBACK_HOST") or "127.0.0.1"
47
+
48
+
49
+ def _format_error_details(error: BaseException, depth: int = 0) -> str:
50
+ details = [f"{type(error).__name__}: {error}"]
51
+ code = getattr(error, "code", None)
52
+ if code:
53
+ details.append(f"code={code}")
54
+ errno = getattr(error, "errno", None)
55
+ if errno is not None:
56
+ details.append(f"errno={errno}")
57
+ cause = error.__cause__ if error.__cause__ is not None or error.__suppress_context__ else error.__context__
58
+ if cause is not None and depth < 8:
59
+ details.append(f"cause={_format_error_details(cause, depth + 1)}")
60
+ if error.__traceback__ is not None:
61
+ details.append(f"stack={''.join(traceback.format_tb(error.__traceback__)).rstrip()}")
62
+ return "; ".join(details)
63
+
64
+
65
+ async def _post_json(url: str, body: dict[str, Any], signal: AbortSignal) -> str:
66
+ response = await _common.fetch(
67
+ url,
68
+ method="POST",
69
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
70
+ body=json_stringify(body),
71
+ signal=signal,
72
+ timeout_ms=_REQUEST_TIMEOUT_MS,
73
+ )
74
+ response_body = response.text
75
+ if not response.is_success:
76
+ raise OAuthError(f"HTTP request failed. status={response.status_code}; url={url}; body={response_body}")
77
+ return response_body
78
+
79
+
80
+ def _credential_from_token(data: Any) -> Credential:
81
+ token = data if isinstance(data, dict) else {}
82
+ credential: Credential = {"type": "oauth"}
83
+ if token.get("refresh_token") is not None:
84
+ credential["refresh"] = token["refresh_token"]
85
+ if token.get("access_token") is not None:
86
+ credential["access"] = token["access_token"]
87
+ credential["expires"] = _common.now_ms() + _common.expires_in_ms(token.get("expires_in")) - _EXPIRY_SKEW_MS
88
+ return credential
89
+
90
+
91
+ async def _exchange_authorization_code(
92
+ code: str, state: str, verifier: str, redirect_uri: str, signal: AbortSignal
93
+ ) -> Credential:
94
+ try:
95
+ response_body = await _post_json(
96
+ TOKEN_URL,
97
+ {
98
+ "grant_type": "authorization_code",
99
+ "client_id": CLIENT_ID,
100
+ "code": code,
101
+ "state": state,
102
+ "redirect_uri": redirect_uri,
103
+ "code_verifier": verifier,
104
+ },
105
+ signal,
106
+ )
107
+ except Exception as error:
108
+ raise OAuthError(
109
+ f"Token exchange request failed. url={TOKEN_URL}; redirect_uri={redirect_uri}; "
110
+ f"response_type=authorization_code; details={_format_error_details(error)}"
111
+ ) from error
112
+
113
+ try:
114
+ token_data = json.loads(response_body)
115
+ except ValueError as error:
116
+ raise OAuthError(
117
+ f"Token exchange returned invalid JSON. url={TOKEN_URL}; body={response_body}; "
118
+ f"details={_format_error_details(error)}"
119
+ ) from error
120
+
121
+ return _credential_from_token(token_data)
122
+
123
+
124
+ def _authorize_url(redirect_uri: str, challenge: str, verifier: str) -> str:
125
+ params = {
126
+ "code": "true",
127
+ "client_id": CLIENT_ID,
128
+ "response_type": "code",
129
+ "redirect_uri": redirect_uri,
130
+ "scope": SCOPES,
131
+ "code_challenge": challenge,
132
+ "code_challenge_method": "S256",
133
+ "state": verifier,
134
+ }
135
+ return f"{AUTHORIZE_URL}?{form_encode(params)}"
136
+
137
+
138
+ async def _login_anthropic(interaction: Interaction) -> Credential:
139
+ verifier, challenge = generate_pkce()
140
+
141
+ async def identity(code: str) -> str:
142
+ return code
143
+
144
+ async def start_callback_server(port: int) -> OAuthCallbackServer[str]:
145
+ return await start_oauth_callback_server(
146
+ provider_name="Anthropic",
147
+ host=_callback_host(),
148
+ port=port,
149
+ path=CALLBACK_PATH,
150
+ redirect_host="localhost",
151
+ state=verifier,
152
+ complete=identity,
153
+ signal=interaction.signal,
154
+ )
155
+
156
+ # Without a callback server, login continues with the pasted redirect URL.
157
+ callback: OAuthCallbackServer[str] | None = None
158
+ for port in (CALLBACK_PORT, 0):
159
+ try:
160
+ callback = await start_callback_server(port)
161
+ break
162
+ except Exception:
163
+ continue
164
+ redirect_uri = callback.redirect_uri if callback is not None else REDIRECT_URI
165
+
166
+ try:
167
+ interaction.notify(
168
+ AuthEvent(
169
+ type="auth_url",
170
+ url=_authorize_url(redirect_uri, challenge, verifier),
171
+ instructions=(
172
+ "Complete login in your browser. If the browser is on another machine, "
173
+ "paste the final redirect URL here."
174
+ ),
175
+ )
176
+ )
177
+
178
+ result = await wait_for_callback_or_manual_input(
179
+ interaction,
180
+ callback,
181
+ message="Complete login in your browser, or paste the authorization code / redirect URL here:",
182
+ placeholder=redirect_uri,
183
+ )
184
+ code: str | None
185
+ state = verifier
186
+ if isinstance(result, CallbackResult):
187
+ code = result.value
188
+ else:
189
+ code, parsed_state = parse_authorization_input(result.input)
190
+ if parsed_state and parsed_state != verifier:
191
+ raise OAuthError("OAuth state mismatch")
192
+ state = parsed_state if parsed_state is not None else verifier
193
+
194
+ if not code:
195
+ raise OAuthError("Missing authorization code")
196
+ interaction.notify(AuthEvent(type="progress", message="Exchanging authorization code for tokens..."))
197
+ return await _exchange_authorization_code(code, state, verifier, redirect_uri, interaction.signal)
198
+ finally:
199
+ if callback is not None:
200
+ callback.close()
201
+
202
+
203
+ async def _login_anthropic_copy_code(interaction: Interaction) -> Credential:
204
+ verifier, challenge = generate_pkce()
205
+ interaction.notify(
206
+ AuthEvent(
207
+ type="auth_url",
208
+ url=_authorize_url(COPY_CODE_REDIRECT_URI, challenge, verifier),
209
+ instructions="Complete login in your browser, then copy the code Anthropic shows and paste it here.",
210
+ )
211
+ )
212
+
213
+ entered = await interaction.prompt(
214
+ AuthPrompt(
215
+ type="manual_code",
216
+ message="Paste the code Anthropic shows after you sign in:",
217
+ placeholder="code#state",
218
+ signal=interaction.signal,
219
+ )
220
+ )
221
+ code, state = parse_authorization_input(entered)
222
+ if state and state != verifier:
223
+ raise OAuthError("OAuth state mismatch")
224
+ if not code:
225
+ raise OAuthError("Missing authorization code")
226
+ interaction.notify(AuthEvent(type="progress", message="Exchanging authorization code for tokens..."))
227
+ return await _exchange_authorization_code(
228
+ code, state if state is not None else verifier, verifier, COPY_CODE_REDIRECT_URI, interaction.signal
229
+ )
230
+
231
+
232
+ async def _refresh_anthropic_token(refresh_token: str, signal: AbortSignal) -> Credential:
233
+ try:
234
+ response_body = await _post_json(
235
+ TOKEN_URL,
236
+ {"grant_type": "refresh_token", "client_id": CLIENT_ID, "refresh_token": refresh_token},
237
+ signal,
238
+ )
239
+ except Exception as error:
240
+ raise OAuthError(
241
+ f"Anthropic token refresh request failed. url={TOKEN_URL}; details={_format_error_details(error)}"
242
+ ) from error
243
+
244
+ try:
245
+ data = json.loads(response_body)
246
+ except ValueError as error:
247
+ raise OAuthError(
248
+ f"Anthropic token refresh returned invalid JSON. url={TOKEN_URL}; body={response_body}; "
249
+ f"details={_format_error_details(error)}"
250
+ ) from error
251
+
252
+ return _credential_from_token(data)
253
+
254
+
255
+ class AnthropicOAuth(OAuthAuth):
256
+ name = "Anthropic (Claude Pro/Max)"
257
+ is_subscription = True
258
+
259
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
260
+ method = await interaction.prompt(
261
+ AuthPrompt(
262
+ type="select",
263
+ message="Select Anthropic login method:",
264
+ options=[
265
+ AuthSelectOption(ANTHROPIC_BROWSER_LOGIN_METHOD, "Browser login (default)"),
266
+ AuthSelectOption(ANTHROPIC_COPY_CODE_LOGIN_METHOD, "Copy code login (headless)"),
267
+ ],
268
+ )
269
+ )
270
+ if method == ANTHROPIC_COPY_CODE_LOGIN_METHOD:
271
+ return await _login_anthropic_copy_code(interaction)
272
+ if method != ANTHROPIC_BROWSER_LOGIN_METHOD:
273
+ raise OAuthError(f"Unknown Anthropic login method: {method}")
274
+ return await _login_anthropic(interaction)
275
+
276
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
277
+ return await _refresh_anthropic_token(credential["refresh"], signal)
278
+
279
+ async def to_auth(self, credential: Credential) -> ModelAuth:
280
+ return ModelAuth(api_key=credential["access"])
281
+
282
+
283
+ anthropic_oauth = AnthropicOAuth()
@@ -0,0 +1,339 @@
1
+ """Loopback OAuth redirect handler shared by the browser sign-in flows.
2
+
3
+ The server is a minimal asyncio HTTP/1.1 listener (one request per connection)
4
+ running on the caller's event loop, so it is cancellable together with the login.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import os
12
+ import socket
13
+ from collections.abc import Awaitable, Callable, Mapping
14
+ from dataclasses import dataclass
15
+ from http import HTTPStatus
16
+ from typing import Any, ClassVar, Generic, TypeVar
17
+
18
+ from ...abort import AbortController, AbortSignal
19
+ from ...utils.oauth_page import oauth_error_html, oauth_success_html
20
+ from ..types import AuthPrompt, ProviderAuthInteraction
21
+ from ._common import OAuthError, ParsedUrl, login_cancelled, parse_url
22
+
23
+ __all__ = [
24
+ "LoopbackHttpServer",
25
+ "OAuthCallbackServer",
26
+ "CallbackResult",
27
+ "ManualInput",
28
+ "start_oauth_callback_server",
29
+ "wait_for_callback_or_manual_input",
30
+ ]
31
+
32
+ T = TypeVar("T")
33
+
34
+ #: ``(method, url) -> (status, html)`` where ``url`` is the request target resolved against the server origin.
35
+ RequestHandler = Callable[[str, ParsedUrl | None], Awaitable[tuple[int, str]]]
36
+
37
+ _MAX_HEAD_BYTES = 64 * 1024
38
+
39
+
40
+ class _Connection:
41
+ __slots__ = ("active", "writer")
42
+
43
+ def __init__(self, writer: asyncio.StreamWriter) -> None:
44
+ self.writer = writer
45
+ self.active = False
46
+
47
+
48
+ class LoopbackHttpServer:
49
+ """Tiny HTTP/1.1 server that answers every request with an HTML page and closes the connection."""
50
+
51
+ def __init__(self, handler: RequestHandler, *, headers: Mapping[str, str] | None = None) -> None:
52
+ self._handler = handler
53
+ self._headers = dict(headers or {"content-type": "text/html; charset=utf-8"})
54
+ self._server: asyncio.Server | None = None
55
+ self._connections: set[_Connection] = set()
56
+ self.port = 0
57
+ self._origin = "http://localhost"
58
+
59
+ async def listen(self, host: str, port: int) -> None:
60
+ """Bind and start serving. Raises ``OSError`` (e.g. ``EADDRINUSE``) when the port is taken."""
61
+ loop = asyncio.get_running_loop()
62
+ # Bind the first resolved address only (like Node's `server.listen(port, host)`), so a
63
+ # free port picked with `port=0` is the same for every client of this server.
64
+ infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE)
65
+ family, sock_type, proto, _, address = infos[0]
66
+ sock = socket.socket(family, sock_type, proto)
67
+ try:
68
+ if os.name != "nt":
69
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
70
+ sock.bind(address)
71
+ sock.listen(511)
72
+ sock.setblocking(False)
73
+ self._server = await asyncio.start_server(self._serve, sock=sock, limit=_MAX_HEAD_BYTES)
74
+ except BaseException:
75
+ sock.close()
76
+ raise
77
+ self.port = sock.getsockname()[1]
78
+ self._origin = f"http://{f'[{host}]' if ':' in host else host}:{self.port}"
79
+
80
+ def close(self) -> None:
81
+ """Stop accepting connections and drop idle ones; requests being handled complete."""
82
+ if self._server is not None:
83
+ self._server.close()
84
+ for connection in list(self._connections):
85
+ if not connection.active:
86
+ self._drop(connection)
87
+
88
+ def close_all_connections(self) -> None:
89
+ for connection in list(self._connections):
90
+ self._drop(connection)
91
+
92
+ def _drop(self, connection: _Connection) -> None:
93
+ self._connections.discard(connection)
94
+ with contextlib.suppress(Exception):
95
+ connection.writer.close()
96
+
97
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
98
+ connection = _Connection(writer)
99
+ self._connections.add(connection)
100
+ try:
101
+ try:
102
+ head = await reader.readuntil(b"\r\n\r\n")
103
+ except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, ConnectionError):
104
+ return
105
+ connection.active = True
106
+ request_line = head.split(b"\r\n", 1)[0].decode("latin-1")
107
+ parts = request_line.split(" ")
108
+ if len(parts) != 3 or not parts[2].startswith("HTTP/"):
109
+ status, html = 400, oauth_error_html("Bad request.")
110
+ else:
111
+ method, target = parts[0], parts[1]
112
+ try:
113
+ status, html = await self._handler(method, parse_url(target, self._origin))
114
+ except Exception:
115
+ status, html = 500, oauth_error_html("Internal error while processing the callback.")
116
+ body = html.encode("utf-8")
117
+ try:
118
+ reason = HTTPStatus(status).phrase
119
+ except ValueError:
120
+ reason = ""
121
+ lines = [f"HTTP/1.1 {status} {reason}"]
122
+ lines.extend(f"{name}: {value}" for name, value in self._headers.items())
123
+ lines.append(f"content-length: {len(body)}")
124
+ lines.append("connection: close")
125
+ writer.write(("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + body)
126
+ await writer.drain()
127
+ except (ConnectionError, RuntimeError):
128
+ pass
129
+ finally:
130
+ self._connections.discard(connection)
131
+ with contextlib.suppress(Exception):
132
+ writer.close()
133
+
134
+
135
+ class OAuthCallbackServer(Generic[T]):
136
+ """A running loopback redirect handler. Create with :func:`start_oauth_callback_server`."""
137
+
138
+ def __init__(
139
+ self,
140
+ *,
141
+ provider_name: str,
142
+ path: str,
143
+ state: str | None,
144
+ complete: Callable[[str], Awaitable[T]],
145
+ ) -> None:
146
+ self.redirect_uri = ""
147
+ self._provider_name = provider_name
148
+ self._path = path
149
+ self._state = state
150
+ self._complete = complete
151
+ self._loop = asyncio.get_running_loop()
152
+ self._future: asyncio.Future[T | None] = self._loop.create_future()
153
+ # A cancelled or closed wait may never be observed.
154
+ self._future.add_done_callback(_consume_future)
155
+ self._claimed = False
156
+ self._settled = False
157
+ self._timer: asyncio.TimerHandle | None = None
158
+ self._remove_abort: Callable[[], None] | None = None
159
+ self._http = LoopbackHttpServer(
160
+ self._handle, headers={"content-type": "text/html; charset=utf-8", "cache-control": "no-store"}
161
+ )
162
+
163
+ def _finish(self, *, value: T | None = None, error: BaseException | None = None) -> None:
164
+ if self._settled:
165
+ return
166
+ self._settled = True
167
+ if self._timer is not None:
168
+ self._timer.cancel()
169
+ if self._remove_abort is not None:
170
+ self._remove_abort()
171
+ if self._future.done():
172
+ return
173
+ if error is not None:
174
+ self._future.set_exception(error)
175
+ else:
176
+ self._future.set_result(value)
177
+
178
+ def _on_abort(self) -> None:
179
+ def finish() -> None:
180
+ self._finish(error=login_cancelled())
181
+
182
+ try:
183
+ running = asyncio.get_running_loop()
184
+ except RuntimeError:
185
+ running = None
186
+ if running is self._loop:
187
+ finish()
188
+ elif not self._loop.is_closed():
189
+ self._loop.call_soon_threadsafe(finish)
190
+
191
+ async def _handle(self, method: str, url: ParsedUrl | None) -> tuple[int, str]:
192
+ if method != "GET" or url is None or url.pathname != self._path:
193
+ return 404, oauth_error_html("Callback route not found.")
194
+ if self._state is not None and url.param("state") != self._state:
195
+ return 400, oauth_error_html("State mismatch.")
196
+ if self._claimed or self._settled:
197
+ return 409, oauth_error_html("This sign-in has already been handled.")
198
+ error = url.param("error")
199
+ if error:
200
+ description = url.param("error_description")
201
+ if description is None:
202
+ description = error
203
+ self._finish(error=OAuthError(f"{self._provider_name} authorization failed: {description}"))
204
+ return 400, oauth_error_html(f"{self._provider_name} authorization failed.", description)
205
+ code = url.param("code")
206
+ if not code:
207
+ return 400, oauth_error_html("Missing authorization code.")
208
+ self._claimed = True
209
+ try:
210
+ value = await self._complete(code)
211
+ except Exception as failure:
212
+ self._finish(error=failure)
213
+ return 502, oauth_error_html(f"{self._provider_name} sign-in failed.", str(failure))
214
+ self._finish(value=value)
215
+ return 200, oauth_success_html(f"Signed in to {self._provider_name}. You may now close this page.")
216
+
217
+ async def wait(self) -> T | None:
218
+ """The result of ``complete``, or ``None`` after :meth:`cancel`.
219
+
220
+ Raises when the provider redirects with an error, ``complete`` fails, the signal
221
+ aborts, or the timeout elapses.
222
+ """
223
+ return await asyncio.shield(self._future)
224
+
225
+ def cancel(self) -> None:
226
+ """Stop waiting for the browser unless a callback is already being completed."""
227
+ if not self._claimed:
228
+ self._finish(value=None)
229
+
230
+ def close(self) -> None:
231
+ self._finish(error=OAuthError("OAuth callback server closed"))
232
+ self._http.close()
233
+
234
+
235
+ async def start_oauth_callback_server(
236
+ *,
237
+ provider_name: str,
238
+ host: str,
239
+ port: int,
240
+ path: str,
241
+ complete: Callable[[str], Awaitable[T]],
242
+ redirect_host: str | None = None,
243
+ state: str | None = None,
244
+ signal: AbortSignal | None = None,
245
+ timeout_ms: float | None = None,
246
+ ) -> OAuthCallbackServer[T]:
247
+ """Listen for the OAuth redirect on ``host:port`` (``0`` picks a free port).
248
+
249
+ ``provider_name`` is shown on the browser page. ``redirect_host`` replaces ``host``
250
+ in ``redirect_uri`` (for example ``localhost``). ``state`` is the expected ``state``
251
+ parameter (omit when the provider sends none). ``complete`` finishes the sign-in with
252
+ the received code before the browser page is sent, so the page can show exchange
253
+ failures; pass ``lambda code: code`` (async) to exchange the code later.
254
+ """
255
+ if signal is not None and signal.aborted:
256
+ raise login_cancelled()
257
+ server: OAuthCallbackServer[T] = OAuthCallbackServer(
258
+ provider_name=provider_name, path=path, state=state, complete=complete
259
+ )
260
+ await server._http.listen(host, port)
261
+ if signal is not None:
262
+ server._remove_abort = signal.add_listener(server._on_abort)
263
+ if timeout_ms is not None:
264
+ server._timer = server._loop.call_later(
265
+ timeout_ms / 1000, lambda: server._finish(error=OAuthError(f"{provider_name} sign-in timed out"))
266
+ )
267
+ effective_host = redirect_host if redirect_host is not None else host
268
+ formatted = f"[{effective_host}]" if ":" in effective_host else effective_host
269
+ server.redirect_uri = f"http://{formatted}:{server._http.port}{path}"
270
+ return server
271
+
272
+
273
+ @dataclass(frozen=True, slots=True)
274
+ class CallbackResult:
275
+ """The browser callback completed with ``value``."""
276
+
277
+ value: Any
278
+ type: ClassVar[str] = "callback"
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class ManualInput:
283
+ """The user pasted ``input`` (a code or redirect URL)."""
284
+
285
+ input: str
286
+ type: ClassVar[str] = "manual"
287
+
288
+
289
+ async def wait_for_callback_or_manual_input(
290
+ interaction: ProviderAuthInteraction,
291
+ callback: OAuthCallbackServer[Any] | None,
292
+ *,
293
+ message: str,
294
+ placeholder: str,
295
+ ) -> CallbackResult | ManualInput:
296
+ """Wait for the browser callback, or for the user to paste the code or redirect URL.
297
+
298
+ The paste prompt covers browsers that cannot reach the loopback server (for example
299
+ over SSH). Without a callback server only the manual prompt is used. Once one side
300
+ wins, the manual prompt's signal aborts so UIs can dismiss it.
301
+ """
302
+ manual_abort = AbortController()
303
+ manual_errors: list[BaseException] = []
304
+
305
+ async def manual() -> str | None:
306
+ try:
307
+ value = await interaction.prompt(
308
+ AuthPrompt(type="manual_code", message=message, placeholder=placeholder, signal=manual_abort.signal)
309
+ )
310
+ except Exception as error:
311
+ manual_errors.append(error)
312
+ if callback is not None:
313
+ callback.cancel()
314
+ return None
315
+ if callback is not None:
316
+ callback.cancel()
317
+ return value
318
+
319
+ manual_task = asyncio.ensure_future(manual())
320
+ try:
321
+ value = await callback.wait() if callback is not None else None
322
+ if manual_errors:
323
+ raise manual_errors[0]
324
+ if value is not None:
325
+ return CallbackResult(value)
326
+ entered = await manual_task
327
+ if manual_errors:
328
+ raise manual_errors[0]
329
+ return ManualInput(entered or "")
330
+ finally:
331
+ manual_abort.abort()
332
+ if not manual_task.done():
333
+ # Prompts that ignore their signal would otherwise stay pending forever.
334
+ manual_task.cancel()
335
+
336
+
337
+ def _consume_future(future: asyncio.Future[Any]) -> None:
338
+ if not future.cancelled():
339
+ future.exception()