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,311 @@
1
+ """Radius gateway OAuth flow.
2
+
3
+ Radius is a pi-messages gateway. OAuth client APIs live on the configured
4
+ gateway; only the interactive browser authorization endpoint is discovered.
5
+ Model catalog loading is owned by the Radius provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import uuid
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+ from urllib.parse import urljoin
16
+
17
+ import httpx
18
+
19
+ from ...abort import AbortSignal
20
+ from ...providers.radius_config import normalize_radius_gateway_url
21
+ from ..types import AuthEvent, AuthPrompt, AuthSelectOption, Credential, LoginOptions, ModelAuth, OAuthAuth
22
+ from ..types import ProviderAuthInteraction as Interaction
23
+ from . import _common
24
+ from ._common import OAuthError, form_encode, parse_url
25
+ from .callback_server import start_oauth_callback_server
26
+ from .device_code import (
27
+ DeviceCodeComplete,
28
+ DeviceCodeFailed,
29
+ DeviceCodePending,
30
+ DeviceCodePollResult,
31
+ DeviceCodeSlowDown,
32
+ poll_oauth_device_code_flow,
33
+ )
34
+ from .pkce import generate_pkce
35
+
36
+ __all__ = ["RadiusOAuth", "RadiusOAuthResponseError", "create_radius_oauth", "normalize_radius_gateway_url"]
37
+
38
+ CALLBACK_HOST = "127.0.0.1"
39
+ CALLBACK_PORT = 1456
40
+ CALLBACK_PATH = "/oauth/callback"
41
+ REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
42
+ TOKEN_EXPIRY_SKEW_MS = 60_000
43
+ LOGIN_METHOD_BROWSER = "browser"
44
+ LOGIN_METHOD_DEVICE_CODE = "device-code"
45
+ OAUTH_CLIENT_ID = "pi-gateway"
46
+ OAUTH_SCOPE = "gateway offline_access"
47
+ OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
48
+
49
+ _FORM_HEADERS = {"accept": "application/json", "content-type": "application/x-www-form-urlencoded"}
50
+
51
+
52
+ class RadiusOAuthResponseError(OAuthError):
53
+ """An OAuth error response from the gateway (``status`` and the OAuth ``error`` code)."""
54
+
55
+ def __init__(self, status: int, oauth_error: str | None, description: str | None, message: str) -> None:
56
+ if oauth_error:
57
+ detail = f"{oauth_error}: {description}" if description else oauth_error
58
+ else:
59
+ detail = description or str(status)
60
+ super().__init__(f"{message}: {detail}")
61
+ self.status = status
62
+ self.oauth_error = oauth_error
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class _DeviceAuthorization:
67
+ device_code: str
68
+ user_code: str
69
+ verification_uri: str
70
+ expires_in: float
71
+ interval: float | None
72
+
73
+
74
+ def _gateway_url(gateway: str, path: str) -> str:
75
+ """``new URL(path, gateway)`` for an absolute path."""
76
+ return urljoin(f"{gateway}/", path)
77
+
78
+
79
+ async def _load_discovery(gateway: str, signal: AbortSignal) -> str:
80
+ response = await _common.fetch(
81
+ _gateway_url(gateway, "/v1/oauth"), headers={"accept": "application/json"}, signal=signal
82
+ )
83
+ if not response.is_success:
84
+ raise OAuthError(f"Could not load Radius OAuth config from {gateway}: {response.status_code} {response.text}")
85
+ discovery = _common.parse_json(response)
86
+ endpoint = discovery.get("authorizationEndpoint") if isinstance(discovery, dict) else None
87
+ if not isinstance(endpoint, str):
88
+ raise OAuthError(f"Invalid Radius OAuth config from {gateway}")
89
+ return endpoint
90
+
91
+
92
+ def _read_oauth_response_error(response: httpx.Response, message: str) -> RadiusOAuthResponseError:
93
+ text = response.text
94
+ oauth_error: str | None = None
95
+ description: str | None = None
96
+ if text:
97
+ try:
98
+ data = json.loads(text)
99
+ except ValueError:
100
+ description = text
101
+ else:
102
+ if isinstance(data, dict):
103
+ oauth_error = data.get("error") if isinstance(data.get("error"), str) else None
104
+ description = data.get("error_description") if isinstance(data.get("error_description"), str) else None
105
+ return RadiusOAuthResponseError(response.status_code, oauth_error, description, message)
106
+
107
+
108
+ async def _request_oauth_token(gateway: str, fields: Mapping[str, str], signal: AbortSignal) -> Credential:
109
+ try:
110
+ response = await _common.fetch(
111
+ _gateway_url(gateway, "/v1/oauth/token"),
112
+ method="POST",
113
+ headers=_FORM_HEADERS,
114
+ body=form_encode(fields),
115
+ signal=signal,
116
+ )
117
+ except Exception:
118
+ if signal.aborted:
119
+ raise _common.login_cancelled() from None
120
+ raise
121
+
122
+ if not response.is_success:
123
+ raise _read_oauth_response_error(response, "Radius OAuth token request failed")
124
+
125
+ data: Any = _common.parse_json(response)
126
+ token = data if isinstance(data, dict) else {}
127
+ credential: Credential = {"type": "oauth"}
128
+ if token.get("access_token") is not None:
129
+ credential["access"] = token["access_token"]
130
+ if token.get("refresh_token") is not None:
131
+ credential["refresh"] = token["refresh_token"]
132
+ credential["expires"] = _common.now_ms() + _common.expires_in_ms(token.get("expires_in")) - TOKEN_EXPIRY_SKEW_MS
133
+ if token.get("scope") is not None:
134
+ credential["scope"] = token["scope"]
135
+ return credential
136
+
137
+
138
+ async def _login_with_browser(gateway: str, authorization_endpoint: str, interaction: Interaction) -> Credential:
139
+ verifier, challenge = generate_pkce()
140
+ state = str(uuid.uuid4())
141
+ authorize_url = parse_url(authorization_endpoint)
142
+ if authorize_url is None:
143
+ raise ValueError(f"Invalid URL: {authorization_endpoint}")
144
+ authorize_url = authorize_url.with_search(
145
+ form_encode(
146
+ {
147
+ "response_type": "code",
148
+ "client_id": OAUTH_CLIENT_ID,
149
+ "redirect_uri": REDIRECT_URI,
150
+ "scope": OAUTH_SCOPE,
151
+ "code_challenge": challenge,
152
+ "code_challenge_method": "S256",
153
+ "handoff": "url",
154
+ "state": state,
155
+ }
156
+ )
157
+ )
158
+
159
+ async def complete(code: str) -> Credential:
160
+ return await _request_oauth_token(
161
+ gateway,
162
+ {
163
+ "grant_type": "authorization_code",
164
+ "client_id": OAUTH_CLIENT_ID,
165
+ "redirect_uri": REDIRECT_URI,
166
+ "code": code,
167
+ "code_verifier": verifier,
168
+ },
169
+ interaction.signal,
170
+ )
171
+
172
+ callback = await start_oauth_callback_server(
173
+ provider_name="Radius",
174
+ host=CALLBACK_HOST,
175
+ port=CALLBACK_PORT,
176
+ path=CALLBACK_PATH,
177
+ state=state,
178
+ complete=complete,
179
+ signal=interaction.signal,
180
+ )
181
+ try:
182
+ interaction.notify(AuthEvent(type="progress", message=f"Listening for OAuth callback on {REDIRECT_URI}"))
183
+ interaction.notify(AuthEvent(type="auth_url", url=authorize_url.href, instructions="Continue in your browser."))
184
+ credential = await callback.wait()
185
+ if not credential:
186
+ raise OAuthError("OAuth callback did not complete.")
187
+ return credential
188
+ finally:
189
+ callback.close()
190
+
191
+
192
+ async def _request_device_authorization(gateway: str, signal: AbortSignal) -> _DeviceAuthorization:
193
+ try:
194
+ response = await _common.fetch(
195
+ _gateway_url(gateway, "/v1/oauth/device"),
196
+ method="POST",
197
+ headers=_FORM_HEADERS,
198
+ body=form_encode({"client_id": OAUTH_CLIENT_ID, "scope": OAUTH_SCOPE}),
199
+ signal=signal,
200
+ )
201
+ except Exception:
202
+ if signal.aborted:
203
+ raise _common.login_cancelled() from None
204
+ raise
205
+
206
+ if not response.is_success:
207
+ raise _read_oauth_response_error(response, "Radius OAuth device authorization failed")
208
+
209
+ data: Any = _common.parse_json(response)
210
+ record = data if isinstance(data, dict) else {}
211
+ if (
212
+ not record.get("device_code")
213
+ or not record.get("user_code")
214
+ or not record.get("verification_uri")
215
+ or not record.get("expires_in")
216
+ ):
217
+ raise OAuthError("Radius OAuth device authorization response is missing required fields")
218
+
219
+ return _DeviceAuthorization(
220
+ device_code=record["device_code"],
221
+ user_code=record["user_code"],
222
+ verification_uri=record["verification_uri"],
223
+ expires_in=record["expires_in"],
224
+ interval=record.get("interval"),
225
+ )
226
+
227
+
228
+ async def _login_with_device_code(gateway: str, interaction: Interaction) -> Credential:
229
+ device = await _request_device_authorization(gateway, interaction.signal)
230
+ interaction.notify(
231
+ AuthEvent(
232
+ type="device_code",
233
+ user_code=device.user_code,
234
+ verification_uri=device.verification_uri,
235
+ interval_seconds=device.interval,
236
+ expires_in_seconds=device.expires_in,
237
+ )
238
+ )
239
+
240
+ async def poll() -> DeviceCodePollResult:
241
+ try:
242
+ credential = await _request_oauth_token(
243
+ gateway,
244
+ {
245
+ "grant_type": OAUTH_DEVICE_CODE_GRANT_TYPE,
246
+ "client_id": OAUTH_CLIENT_ID,
247
+ "device_code": device.device_code,
248
+ },
249
+ interaction.signal,
250
+ )
251
+ return DeviceCodeComplete(credential)
252
+ except RadiusOAuthResponseError as error:
253
+ if error.oauth_error == "authorization_pending":
254
+ return DeviceCodePending()
255
+ if error.oauth_error == "slow_down":
256
+ return DeviceCodeSlowDown()
257
+ if error.oauth_error == "expired_token":
258
+ return DeviceCodeFailed("Device authorization expired.")
259
+ if error.oauth_error == "access_denied":
260
+ return DeviceCodeFailed("Device authorization was denied.")
261
+ raise
262
+
263
+ return await poll_oauth_device_code_flow(
264
+ poll=poll,
265
+ signal=interaction.signal,
266
+ interval_seconds=device.interval,
267
+ expires_in_seconds=device.expires_in,
268
+ )
269
+
270
+
271
+ class RadiusOAuth(OAuthAuth):
272
+ """OAuth against one Radius gateway."""
273
+
274
+ def __init__(self, *, name: str, gateway: str) -> None:
275
+ self.name = name
276
+ self.gateway = normalize_radius_gateway_url(gateway)
277
+
278
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
279
+ login_method = await interaction.prompt(
280
+ AuthPrompt(
281
+ type="select",
282
+ message=f"Sign in to {self.name}:",
283
+ options=[
284
+ AuthSelectOption(LOGIN_METHOD_BROWSER, "Sign in with browser (recommended)"),
285
+ AuthSelectOption(
286
+ LOGIN_METHOD_DEVICE_CODE, "Sign in with device code (when signing in from another device)"
287
+ ),
288
+ ],
289
+ )
290
+ )
291
+ if login_method == LOGIN_METHOD_DEVICE_CODE:
292
+ return await _login_with_device_code(self.gateway, interaction)
293
+ if login_method == LOGIN_METHOD_BROWSER:
294
+ authorization_endpoint = await _load_discovery(self.gateway, interaction.signal)
295
+ return await _login_with_browser(self.gateway, authorization_endpoint, interaction)
296
+ raise OAuthError(f"Unknown {self.name} sign-in method: {login_method}")
297
+
298
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
299
+ return await _request_oauth_token(
300
+ self.gateway,
301
+ {"grant_type": "refresh_token", "client_id": OAUTH_CLIENT_ID, "refresh_token": credential["refresh"]},
302
+ signal,
303
+ )
304
+
305
+ async def to_auth(self, credential: Credential) -> ModelAuth:
306
+ return ModelAuth(api_key=credential["access"])
307
+
308
+
309
+ def create_radius_oauth(*, name: str, gateway: str) -> OAuthAuth:
310
+ """OAuth for the Radius gateway at ``gateway`` (scheme optional), shown as ``name``."""
311
+ return RadiusOAuth(name=name, gateway=gateway)
@@ -0,0 +1,220 @@
1
+ """xAI OAuth device-code flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from ...abort import AbortSignal
11
+ from ..types import AuthEvent, Credential, LoginOptions, ModelAuth, OAuthAuth
12
+ from ..types import ProviderAuthInteraction as Interaction
13
+ from . import _common
14
+ from ._common import OAuthError, form_encode, parse_url
15
+ from .device_code import (
16
+ DeviceCodeComplete,
17
+ DeviceCodeFailed,
18
+ DeviceCodePending,
19
+ DeviceCodePollResult,
20
+ DeviceCodeSlowDown,
21
+ poll_oauth_device_code_flow,
22
+ )
23
+
24
+ __all__ = ["XaiOAuth", "xai_oauth"]
25
+
26
+ XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
27
+ XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"
28
+ XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code"
29
+ XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token"
30
+ # Refresh slightly before the reported expiry to avoid using a token that dies mid-request.
31
+ REFRESH_SKEW_MS = 5 * 60 * 1000
32
+ DEFAULT_TOKEN_LIFETIME_SECONDS = 3600
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class _OAuthHttpResponse:
37
+ ok: bool
38
+ status: int
39
+ body: dict[str, Any]
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class _XaiDeviceCode:
44
+ device_code: str
45
+ user_code: str
46
+ verification_uri: str
47
+ verification_uri_complete: str | None
48
+ interval_seconds: float | None
49
+ expires_in_seconds: float
50
+
51
+
52
+ def _required_string(body: Mapping[str, Any], field: str) -> str:
53
+ value = body.get(field)
54
+ if not isinstance(value, str) or not value:
55
+ raise OAuthError(f"Invalid xAI OAuth response field: {field}")
56
+ return value
57
+
58
+
59
+ def _positive_number(body: Mapping[str, Any], field: str) -> float:
60
+ value = body.get(field)
61
+ if not _common.is_number(value) or not math.isfinite(value) or value <= 0:
62
+ raise OAuthError(f"Invalid xAI OAuth response field: {field}")
63
+ return value
64
+
65
+
66
+ def _validate_verification_uri(raw: str) -> str:
67
+ # The verification URI is opened in the user's browser; force it to be an https URL
68
+ # so a malicious response cannot make the launcher open something else.
69
+ url = parse_url(raw)
70
+ if url is None or url.protocol != "https:":
71
+ raise OAuthError("Untrusted verification URI in xAI OAuth response")
72
+ return url.href
73
+
74
+
75
+ async def _post_form(url: str, fields: Mapping[str, str], signal: AbortSignal) -> _OAuthHttpResponse:
76
+ try:
77
+ response = await _common.fetch(
78
+ url,
79
+ method="POST",
80
+ headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"},
81
+ body=form_encode(fields),
82
+ signal=signal,
83
+ )
84
+ except Exception:
85
+ if signal.aborted:
86
+ raise _common.login_cancelled() from None
87
+ raise
88
+
89
+ try:
90
+ parsed = _common.parse_json(response)
91
+ except ValueError:
92
+ if signal.aborted:
93
+ raise _common.login_cancelled() from None
94
+ raise OAuthError(f"xAI OAuth returned invalid JSON (HTTP {response.status_code})") from None
95
+ return _OAuthHttpResponse(response.is_success, response.status_code, parsed if isinstance(parsed, dict) else {})
96
+
97
+
98
+ def _request_failure(action: str, response: _OAuthHttpResponse) -> OAuthError:
99
+ error = response.body.get("error")
100
+ description = response.body.get("error_description")
101
+ detail = ": ".join(part for part in (error, description) if isinstance(part, str) and part)
102
+ return OAuthError(f"xAI OAuth {action} failed (HTTP {response.status}){f': {detail}' if detail else ''}")
103
+
104
+
105
+ def _parse_device_code(body: Mapping[str, Any]) -> _XaiDeviceCode:
106
+ # RFC 8628 allows interval 0 (no minimum wait); fall back to the poller's
107
+ # default instead of failing on non-positive or malformed values.
108
+ interval = body.get("interval")
109
+ interval_seconds = interval if _common.is_number(interval) and math.isfinite(interval) and interval > 0 else None
110
+ complete = body.get("verification_uri_complete")
111
+ verification_uri_complete = _validate_verification_uri(complete) if isinstance(complete, str) and complete else None
112
+ return _XaiDeviceCode(
113
+ device_code=_required_string(body, "device_code"),
114
+ user_code=_required_string(body, "user_code"),
115
+ verification_uri=_validate_verification_uri(_required_string(body, "verification_uri")),
116
+ verification_uri_complete=verification_uri_complete,
117
+ interval_seconds=interval_seconds,
118
+ expires_in_seconds=_positive_number(body, "expires_in"),
119
+ )
120
+
121
+
122
+ def _credentials_from_token_response(body: Mapping[str, Any], previous_refresh_token: str | None = None) -> Credential:
123
+ access = _required_string(body, "access_token")
124
+ # xAI may omit refresh_token on refresh when the token is not rotated.
125
+ if "refresh_token" not in body and previous_refresh_token:
126
+ refresh = previous_refresh_token
127
+ else:
128
+ refresh = _required_string(body, "refresh_token")
129
+ expires_in_seconds = (
130
+ DEFAULT_TOKEN_LIFETIME_SECONDS if "expires_in" not in body else _positive_number(body, "expires_in")
131
+ )
132
+ return {
133
+ "type": "oauth",
134
+ "access": access,
135
+ "refresh": refresh,
136
+ "expires": _common.now_ms() + _common.expires_in_ms(expires_in_seconds) - REFRESH_SKEW_MS,
137
+ }
138
+
139
+
140
+ async def _request_device_code(signal: AbortSignal) -> _XaiDeviceCode:
141
+ response = await _post_form(
142
+ XAI_DEVICE_CODE_URL, {"client_id": XAI_CLIENT_ID, "scope": XAI_SCOPE, "referrer": "pi"}, signal
143
+ )
144
+ if not response.ok:
145
+ raise _request_failure("device authorization", response)
146
+ return _parse_device_code(response.body)
147
+
148
+
149
+ async def _poll_for_tokens(device: _XaiDeviceCode, signal: AbortSignal) -> Credential:
150
+ async def poll() -> DeviceCodePollResult:
151
+ response = await _post_form(
152
+ XAI_TOKEN_URL,
153
+ {
154
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
155
+ "client_id": XAI_CLIENT_ID,
156
+ "device_code": device.device_code,
157
+ },
158
+ signal,
159
+ )
160
+ if response.ok:
161
+ return DeviceCodeComplete(_credentials_from_token_response(response.body))
162
+
163
+ error = response.body.get("error")
164
+ if error == "authorization_pending":
165
+ return DeviceCodePending()
166
+ if error == "slow_down":
167
+ interval = response.body.get("interval")
168
+ return DeviceCodeSlowDown(interval if _common.is_number(interval) else None)
169
+ if error in ("access_denied", "authorization_denied"):
170
+ return DeviceCodeFailed("xAI device authorization was denied")
171
+ if error == "expired_token":
172
+ return DeviceCodeFailed("xAI device code expired")
173
+ return DeviceCodeFailed(str(_request_failure("device token polling", response)))
174
+
175
+ return await poll_oauth_device_code_flow(
176
+ poll=poll,
177
+ signal=signal,
178
+ interval_seconds=device.interval_seconds,
179
+ expires_in_seconds=device.expires_in_seconds,
180
+ wait_before_first_poll=True,
181
+ )
182
+
183
+
184
+ async def _refresh_xai_token(refresh_token: str, signal: AbortSignal) -> Credential:
185
+ response = await _post_form(
186
+ XAI_TOKEN_URL,
187
+ {"grant_type": "refresh_token", "client_id": XAI_CLIENT_ID, "refresh_token": refresh_token},
188
+ signal,
189
+ )
190
+ if not response.ok:
191
+ raise _request_failure("token refresh", response)
192
+ return _credentials_from_token_response(response.body, refresh_token)
193
+
194
+
195
+ class XaiOAuth(OAuthAuth):
196
+ name = "xAI (Grok/X subscription)"
197
+ is_subscription = True
198
+ login_label = "Sign in with SuperGrok or X Premium"
199
+
200
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
201
+ device = await _request_device_code(interaction.signal)
202
+ interaction.notify(
203
+ AuthEvent(
204
+ type="device_code",
205
+ user_code=device.user_code,
206
+ verification_uri=device.verification_uri_complete or device.verification_uri,
207
+ interval_seconds=device.interval_seconds,
208
+ expires_in_seconds=device.expires_in_seconds,
209
+ )
210
+ )
211
+ return await _poll_for_tokens(device, interaction.signal)
212
+
213
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
214
+ return await _refresh_xai_token(credential["refresh"], signal)
215
+
216
+ async def to_auth(self, credential: Credential) -> ModelAuth:
217
+ return ModelAuth(api_key=credential["access"])
218
+
219
+
220
+ xai_oauth = XaiOAuth()