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
+ """Kimi Code (subscription) OAuth flow.
2
+
3
+ RFC 8628 device authorization grant against https://auth.kimi.com with JSON
4
+ responses. The access token authenticates requests to https://api.kimi.com/coding
5
+ as an ``Authorization: Bearer`` header.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from ...abort import AbortError, AbortSignal
18
+ from ...utils.misc import get_provider_env_value
19
+ from ..types import AuthEvent, Credential, LoginOptions, ModelAuth, OAuthAuth
20
+ from ..types import ProviderAuthInteraction as Interaction
21
+ from . import _common
22
+ from ._common import OAuthError, form_encode, json_stringify, parse_url
23
+ from .device_code import (
24
+ DeviceCodeComplete,
25
+ DeviceCodeFailed,
26
+ DeviceCodePending,
27
+ DeviceCodePollResult,
28
+ DeviceCodeSlowDown,
29
+ poll_oauth_device_code_flow,
30
+ )
31
+
32
+ __all__ = ["KimiCodingOAuth", "kimi_coding_oauth"]
33
+
34
+ CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
35
+ DEFAULT_OAUTH_HOST = "https://auth.kimi.com"
36
+ DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
37
+ DEFAULT_POLL_INTERVAL_SECONDS = 5
38
+ REQUEST_TIMEOUT_MS = 30 * 1000
39
+ REFRESH_MAX_RETRIES = 3
40
+
41
+ _FORM_HEADERS = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class _DeviceAuthorization:
46
+ device_code: str
47
+ user_code: str
48
+ verification_uri: str
49
+ verification_uri_complete: str
50
+ interval_seconds: float
51
+ expires_in_seconds: float
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class _TokenResponse:
56
+ access: str
57
+ refresh: str
58
+ expires: int
59
+
60
+
61
+ def _oauth_host() -> str:
62
+ override = get_provider_env_value("KIMI_CODE_OAUTH_HOST") or get_provider_env_value("KIMI_OAUTH_HOST")
63
+ return re.sub(r"/+$", "", override or DEFAULT_OAUTH_HOST)
64
+
65
+
66
+ def _read_json(response: httpx.Response) -> Any:
67
+ """The JSON body when it is an object or array, otherwise ``None``."""
68
+ try:
69
+ value = _common.parse_json(response)
70
+ except ValueError:
71
+ return None
72
+ return value if isinstance(value, (dict, list)) else None
73
+
74
+
75
+ def _field(data: Any, key: str) -> Any:
76
+ return data.get(key) if isinstance(data, dict) else None
77
+
78
+
79
+ def _trusted_http_url(value: Any) -> str | None:
80
+ """The verification URI is opened in the user's browser; only http(s) URLs are trusted."""
81
+ if not isinstance(value, str) or not value:
82
+ return None
83
+ url = parse_url(value)
84
+ if url is None or url.protocol not in ("https:", "http:"):
85
+ return None
86
+ return url.href
87
+
88
+
89
+ def _positive(value: Any) -> bool:
90
+ return _common.is_number(value) and math.isfinite(value) and value > 0
91
+
92
+
93
+ async def _start_device_authorization(oauth_host: str, signal: AbortSignal) -> _DeviceAuthorization:
94
+ response = await _common.fetch(
95
+ f"{oauth_host}/api/oauth/device_authorization",
96
+ method="POST",
97
+ headers=_FORM_HEADERS,
98
+ body=form_encode({"client_id": CLIENT_ID}),
99
+ signal=signal,
100
+ timeout_ms=REQUEST_TIMEOUT_MS,
101
+ )
102
+
103
+ if not response.is_success:
104
+ text = response.text
105
+ raise OAuthError(
106
+ f"Kimi Code device authorization failed with status {response.status_code}{f': {text}' if text else ''}"
107
+ )
108
+
109
+ data = _read_json(response)
110
+ device_code = _field(data, "device_code")
111
+ user_code = _field(data, "user_code")
112
+ verification_uri = _field(data, "verification_uri")
113
+ verification_uri_complete = _field(data, "verification_uri_complete")
114
+ if (
115
+ not isinstance(device_code, str)
116
+ or not isinstance(user_code, str)
117
+ or not isinstance(verification_uri, str)
118
+ or not isinstance(verification_uri_complete, str)
119
+ or not _trusted_http_url(verification_uri_complete)
120
+ or not _trusted_http_url(verification_uri)
121
+ ):
122
+ raise OAuthError(f"Invalid Kimi Code device authorization response: {json_stringify(data)}")
123
+
124
+ interval = _field(data, "interval")
125
+ expires_in = _field(data, "expires_in")
126
+ return _DeviceAuthorization(
127
+ device_code=device_code,
128
+ user_code=user_code,
129
+ verification_uri=verification_uri,
130
+ verification_uri_complete=verification_uri_complete,
131
+ interval_seconds=interval if _positive(interval) else DEFAULT_POLL_INTERVAL_SECONDS,
132
+ expires_in_seconds=expires_in if _positive(expires_in) else DEVICE_CODE_TIMEOUT_SECONDS,
133
+ )
134
+
135
+
136
+ def _parse_token_response(data: Any, operation: str) -> _TokenResponse:
137
+ access_token = _field(data, "access_token")
138
+ refresh_token = _field(data, "refresh_token")
139
+ expires_in = _field(data, "expires_in")
140
+ if (
141
+ not isinstance(access_token, str)
142
+ or not access_token
143
+ or not isinstance(refresh_token, str)
144
+ or not refresh_token
145
+ or not _positive(expires_in)
146
+ ):
147
+ raise OAuthError(f"Kimi Code token {operation} response missing fields: {json_stringify(data)}")
148
+ return _TokenResponse(access_token, refresh_token, _common.now_ms() + _common.expires_in_ms(expires_in))
149
+
150
+
151
+ async def _poll_for_token(oauth_host: str, device: _DeviceAuthorization, signal: AbortSignal) -> _TokenResponse:
152
+ async def poll() -> DeviceCodePollResult:
153
+ response = await _common.fetch(
154
+ f"{oauth_host}/api/oauth/token",
155
+ method="POST",
156
+ headers=_FORM_HEADERS,
157
+ body=form_encode(
158
+ {
159
+ "client_id": CLIENT_ID,
160
+ "device_code": device.device_code,
161
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
162
+ }
163
+ ),
164
+ signal=signal,
165
+ timeout_ms=REQUEST_TIMEOUT_MS,
166
+ )
167
+
168
+ if response.status_code >= 500:
169
+ text = response.text
170
+ return DeviceCodeFailed(
171
+ f"Kimi Code device token request failed with status {response.status_code}{f': {text}' if text else ''}"
172
+ )
173
+
174
+ data = _read_json(response)
175
+ if response.is_success and isinstance(_field(data, "access_token"), str):
176
+ try:
177
+ return DeviceCodeComplete(_parse_token_response(data, "poll"))
178
+ except Exception as error:
179
+ return DeviceCodeFailed(str(error))
180
+
181
+ error = _field(data, "error")
182
+ error_description = _field(data, "error_description")
183
+ description = f": {error_description}" if isinstance(error_description, str) else ""
184
+ if error == "authorization_pending":
185
+ return DeviceCodePending()
186
+ if error == "slow_down":
187
+ interval = _field(data, "interval")
188
+ return DeviceCodeSlowDown(interval if _common.is_number(interval) and interval > 0 else None)
189
+ if error == "expired_token":
190
+ return DeviceCodeFailed("Kimi Code device authorization expired. Please restart login.")
191
+ if error == "access_denied":
192
+ return DeviceCodeFailed("Kimi Code login was denied.")
193
+ return DeviceCodeFailed(
194
+ f"Kimi Code device token request failed (status {response.status_code})"
195
+ f"{f': {error}{description}' if isinstance(error, str) else ''}"
196
+ )
197
+
198
+ return await poll_oauth_device_code_flow(
199
+ poll=poll,
200
+ signal=signal,
201
+ interval_seconds=device.interval_seconds,
202
+ expires_in_seconds=device.expires_in_seconds,
203
+ wait_before_first_poll=True,
204
+ )
205
+
206
+
207
+ def _is_retryable_refresh_failure(response: httpx.Response) -> bool:
208
+ return response.status_code == 429 or response.status_code >= 500
209
+
210
+
211
+ async def _refresh_token(oauth_host: str, refresh_token: str, signal: AbortSignal) -> _TokenResponse:
212
+ last_error: BaseException | None = None
213
+ for attempt in range(REFRESH_MAX_RETRIES + 1):
214
+ if attempt > 0:
215
+ await _common.sleep(2 ** (attempt - 1), signal)
216
+ if signal.aborted:
217
+ raise AbortError("Kimi Code token refresh aborted")
218
+
219
+ try:
220
+ response = await _common.fetch(
221
+ f"{oauth_host}/api/oauth/token",
222
+ method="POST",
223
+ headers=_FORM_HEADERS,
224
+ body=form_encode(
225
+ {"client_id": CLIENT_ID, "grant_type": "refresh_token", "refresh_token": refresh_token}
226
+ ),
227
+ signal=signal,
228
+ timeout_ms=REQUEST_TIMEOUT_MS,
229
+ )
230
+ except Exception as error:
231
+ last_error = error
232
+ continue
233
+
234
+ data = _read_json(response)
235
+ if response.is_success:
236
+ return _parse_token_response(data, "refresh")
237
+
238
+ # Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
239
+ if response.status_code in (401, 403) or _field(data, "error") == "invalid_grant":
240
+ error_description = _field(data, "error_description")
241
+ description = f": {error_description}" if isinstance(error_description, str) else ""
242
+ raise OAuthError(f"Kimi Code token refresh unauthorized (status {response.status_code}){description}")
243
+
244
+ if _is_retryable_refresh_failure(response) and attempt < REFRESH_MAX_RETRIES:
245
+ last_error = OAuthError(f"Kimi Code token refresh failed with status {response.status_code}")
246
+ continue
247
+
248
+ raise OAuthError(f"Kimi Code token refresh failed with status {response.status_code}: {json_stringify(data)}")
249
+
250
+ raise last_error if last_error is not None else OAuthError("Kimi Code token refresh failed")
251
+
252
+
253
+ def _credential(token: _TokenResponse) -> Credential:
254
+ return {"type": "oauth", "access": token.access, "refresh": token.refresh, "expires": token.expires}
255
+
256
+
257
+ class KimiCodingOAuth(OAuthAuth):
258
+ name = "Kimi Code (subscription)"
259
+ is_subscription = True
260
+ login_label = "Sign in with Kimi Code"
261
+
262
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
263
+ oauth_host = _oauth_host()
264
+ device = await _start_device_authorization(oauth_host, interaction.signal)
265
+ interaction.notify(
266
+ AuthEvent(
267
+ type="device_code",
268
+ user_code=device.user_code,
269
+ verification_uri=device.verification_uri_complete,
270
+ interval_seconds=device.interval_seconds,
271
+ expires_in_seconds=device.expires_in_seconds,
272
+ )
273
+ )
274
+ return _credential(await _poll_for_token(oauth_host, device, interaction.signal))
275
+
276
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
277
+ return _credential(await _refresh_token(_oauth_host(), credential["refresh"], signal))
278
+
279
+ async def to_auth(self, credential: Credential) -> ModelAuth:
280
+ return ModelAuth(headers={"Authorization": f"Bearer {credential['access']}"})
281
+
282
+
283
+ kimi_coding_oauth = KimiCodingOAuth()
@@ -0,0 +1,64 @@
1
+ """Loaders for the built-in OAuth flows.
2
+
3
+ Provider factories pass these to :func:`pi_ai.lazy_oauth` so flow modules (local
4
+ callback servers, PKCE helpers) import only when a login or refresh happens.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from typing import Any
11
+
12
+ from ..types import OAuthAuth
13
+
14
+ __all__ = [
15
+ "load_anthropic_oauth",
16
+ "load_openai_codex_oauth",
17
+ "load_openai_chatgpt_oauth",
18
+ "load_github_copilot_oauth",
19
+ "load_openrouter_oauth",
20
+ "load_kimi_coding_oauth",
21
+ "load_meta_oauth",
22
+ "load_xai_oauth",
23
+ "load_radius_oauth",
24
+ ]
25
+
26
+
27
+ def _load(module: str, attribute: str) -> Any:
28
+ return getattr(importlib.import_module(f"pi_ai.auth.oauth.{module}"), attribute)
29
+
30
+
31
+ def load_anthropic_oauth() -> OAuthAuth:
32
+ return _load("anthropic", "anthropic_oauth")
33
+
34
+
35
+ def load_openai_codex_oauth() -> OAuthAuth:
36
+ return _load("openai_codex", "openai_codex_oauth")
37
+
38
+
39
+ def load_openai_chatgpt_oauth() -> OAuthAuth:
40
+ return _load("openai_chatgpt", "openai_chatgpt_oauth")
41
+
42
+
43
+ def load_github_copilot_oauth() -> OAuthAuth:
44
+ return _load("github_copilot", "github_copilot_oauth")
45
+
46
+
47
+ def load_openrouter_oauth() -> OAuthAuth:
48
+ return _load("openrouter", "openrouter_oauth")
49
+
50
+
51
+ def load_kimi_coding_oauth() -> OAuthAuth:
52
+ return _load("kimi_coding", "kimi_coding_oauth")
53
+
54
+
55
+ def load_meta_oauth() -> OAuthAuth:
56
+ return _load("meta", "meta_oauth")
57
+
58
+
59
+ def load_xai_oauth() -> OAuthAuth:
60
+ return _load("xai", "xai_oauth")
61
+
62
+
63
+ def load_radius_oauth(*, name: str, gateway: str) -> OAuthAuth:
64
+ return _load("radius", "create_radius_oauth")(name=name, gateway=gateway)
@@ -0,0 +1,238 @@
1
+ """Meta Model API OAuth flow.
2
+
3
+ RFC 8628 device authorization grant against https://auth.meta.com (JSON
4
+ responses). Meta splits identity from API access: the resulting identity token
5
+ is not accepted for inference, so it is exchanged for a Model API key via the
6
+ Muse Code key-mint endpoint (minted keys live about a day). The identity token
7
+ is stored as ``refresh`` and the minted key as ``access``, so the standard OAuth
8
+ scheduler re-mints the key when it expires with no bespoke renewal machinery.
9
+ The identity token itself is not renewable (auth.meta.com answers
10
+ ``grant_type=refresh_token`` with 404 and issues no refresh_token), so a 401/403
11
+ from mint means the session is dead and the user must sign in again.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from ...abort import AbortSignal
23
+ from ..types import AuthEvent, Credential, LoginOptions, ModelAuth, OAuthAuth
24
+ from ..types import ProviderAuthInteraction as Interaction
25
+ from . import _common
26
+ from ._common import OAuthError, form_encode, json_stringify, parse_url
27
+ from .device_code import (
28
+ DeviceCodeComplete,
29
+ DeviceCodeFailed,
30
+ DeviceCodePending,
31
+ DeviceCodePollResult,
32
+ DeviceCodeSlowDown,
33
+ poll_oauth_device_code_flow,
34
+ )
35
+
36
+ __all__ = ["MetaOAuth", "meta_oauth"]
37
+
38
+ # Muse Code CLI client id.
39
+ CLIENT_ID = "1031625952748946"
40
+ AUTH_HOST = "https://auth.meta.com"
41
+ DEVICE_AUTHORIZATION_URL = f"{AUTH_HOST}/oidc/device/authorization/"
42
+ DEVICE_TOKEN_URL = f"{AUTH_HOST}/oidc/device/token/"
43
+ API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key"
44
+ API_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000
45
+ REQUEST_TIMEOUT_MS = 30 * 1000
46
+
47
+ _FORM_HEADERS = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class _DeviceAuthorization:
52
+ device_code: str
53
+ user_code: str
54
+ verification_uri: str
55
+ interval_seconds: float | None
56
+ expires_in_seconds: float | None
57
+
58
+
59
+ def _read_json(response: httpx.Response) -> Any:
60
+ try:
61
+ value = _common.parse_json(response)
62
+ except ValueError:
63
+ return None
64
+ return value if isinstance(value, (dict, list)) else None
65
+
66
+
67
+ def _field(data: Any, key: str) -> Any:
68
+ return data.get(key) if isinstance(data, dict) else None
69
+
70
+
71
+ def _error_detail(data: Any) -> str:
72
+ for key in ("error_description", "detail", "message", "error"):
73
+ value = _field(data, key)
74
+ if isinstance(value, str) and value.strip():
75
+ return f": {value.strip()}"
76
+ return ""
77
+
78
+
79
+ def _trusted_http_url(value: Any) -> str | None:
80
+ """The verification URI is opened in the user's browser; only http(s) URLs are trusted."""
81
+ if not isinstance(value, str) or not value:
82
+ return None
83
+ url = parse_url(value)
84
+ if url is None or url.protocol not in ("https:", "http:"):
85
+ return None
86
+ return url.href
87
+
88
+
89
+ def _positive_number(value: Any) -> float | None:
90
+ return value if _common.is_number(value) and math.isfinite(value) and value > 0 else None
91
+
92
+
93
+ async def _start_device_authorization(signal: AbortSignal) -> _DeviceAuthorization:
94
+ response = await _common.fetch(
95
+ DEVICE_AUTHORIZATION_URL,
96
+ method="POST",
97
+ headers=_FORM_HEADERS,
98
+ body=form_encode({"client_id": CLIENT_ID}),
99
+ signal=signal,
100
+ timeout_ms=REQUEST_TIMEOUT_MS,
101
+ )
102
+ data = _read_json(response)
103
+ if not response.is_success:
104
+ raise OAuthError(f"Meta device authorization failed with status {response.status_code}{_error_detail(data)}")
105
+ device_code = _field(data, "device_code")
106
+ user_code = _field(data, "user_code")
107
+ verification_uri = _trusted_http_url(_field(data, "verification_uri_complete")) or _trusted_http_url(
108
+ _field(data, "verification_uri")
109
+ )
110
+ if not isinstance(device_code, str) or not device_code or not isinstance(user_code, str) or not user_code:
111
+ raise OAuthError(f"Invalid Meta device authorization response: {json_stringify(data)}")
112
+ if not verification_uri:
113
+ raise OAuthError(f"Invalid Meta device authorization response: {json_stringify(data)}")
114
+ return _DeviceAuthorization(
115
+ device_code=device_code,
116
+ user_code=user_code,
117
+ verification_uri=verification_uri,
118
+ interval_seconds=_positive_number(_field(data, "interval")),
119
+ expires_in_seconds=_positive_number(_field(data, "expires_in")),
120
+ )
121
+
122
+
123
+ async def _poll_for_identity_token(device: _DeviceAuthorization, signal: AbortSignal) -> str:
124
+ async def poll() -> DeviceCodePollResult:
125
+ response = await _common.fetch(
126
+ DEVICE_TOKEN_URL,
127
+ method="POST",
128
+ headers=_FORM_HEADERS,
129
+ body=form_encode(
130
+ {
131
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
132
+ "device_code": device.device_code,
133
+ "client_id": CLIENT_ID,
134
+ }
135
+ ),
136
+ signal=signal,
137
+ timeout_ms=REQUEST_TIMEOUT_MS,
138
+ )
139
+ data = _read_json(response)
140
+ access_token = _field(data, "access_token")
141
+ if response.is_success and isinstance(access_token, str) and access_token:
142
+ return DeviceCodeComplete(access_token)
143
+ error = _field(data, "error")
144
+ if error == "authorization_pending":
145
+ return DeviceCodePending()
146
+ if error == "slow_down":
147
+ return DeviceCodeSlowDown(_positive_number(_field(data, "interval")))
148
+ if error == "access_denied":
149
+ return DeviceCodeFailed("Meta login was denied.")
150
+ if error == "expired_token":
151
+ return DeviceCodeFailed("Meta device authorization expired. Please restart login.")
152
+ return DeviceCodeFailed(
153
+ f"Meta device token request failed with status {response.status_code}{_error_detail(data)}"
154
+ )
155
+
156
+ return await poll_oauth_device_code_flow(
157
+ poll=poll,
158
+ signal=signal,
159
+ interval_seconds=device.interval_seconds,
160
+ expires_in_seconds=device.expires_in_seconds,
161
+ wait_before_first_poll=True,
162
+ )
163
+
164
+
165
+ async def _mint_api_key(identity_token: str, signal: AbortSignal) -> Credential:
166
+ """Exchange an identity token for a Model API key. Keys are valid for about a day."""
167
+ response = await _common.fetch(
168
+ API_KEY_MINT_URL,
169
+ method="POST",
170
+ headers={
171
+ "Accept": "application/json",
172
+ "Authorization": f"Bearer {identity_token}",
173
+ "Content-Type": "application/json",
174
+ "x-api-version": "1.0.0",
175
+ },
176
+ body="{}",
177
+ signal=signal,
178
+ timeout_ms=REQUEST_TIMEOUT_MS,
179
+ )
180
+ data = _read_json(response)
181
+ if response.status_code in (401, 403):
182
+ # Identity token is not renewable (see module docstring); only a fresh device flow helps.
183
+ raise OAuthError(
184
+ f"Meta session expired (status {response.status_code}). Run `/login meta` to sign in again."
185
+ f"{_error_detail(data)}"
186
+ )
187
+ if not response.is_success:
188
+ raise OAuthError(f"Meta API key mint failed with status {response.status_code}{_error_detail(data)}")
189
+ api_key = _field(data, "api_key")
190
+ if not isinstance(api_key, str) or not api_key:
191
+ action_url = _trusted_http_url(_field(data, "action_url"))
192
+ raise OAuthError(f"Meta did not issue an API key.{f' Complete setup at {action_url}' if action_url else ''}")
193
+ return {
194
+ "type": "oauth",
195
+ "refresh": identity_token,
196
+ "access": api_key,
197
+ "expires": _common.now_ms() + API_KEY_LIFETIME_MS,
198
+ }
199
+
200
+
201
+ async def _login_meta(interaction: Interaction) -> Credential:
202
+ try:
203
+ device = await _start_device_authorization(interaction.signal)
204
+ interaction.notify(
205
+ AuthEvent(
206
+ type="device_code",
207
+ user_code=device.user_code,
208
+ verification_uri=device.verification_uri,
209
+ interval_seconds=device.interval_seconds,
210
+ expires_in_seconds=device.expires_in_seconds,
211
+ )
212
+ )
213
+ identity_token = await _poll_for_identity_token(device, interaction.signal)
214
+ interaction.notify(AuthEvent(type="progress", message="Enabling Meta Model API access..."))
215
+ return await _mint_api_key(identity_token, interaction.signal)
216
+ except Exception as error:
217
+ # An in-flight request raises an AbortError on abort; the login UI matches on this message.
218
+ if interaction.signal.aborted:
219
+ raise _common.login_cancelled() from error
220
+ raise
221
+
222
+
223
+ class MetaOAuth(OAuthAuth):
224
+ name = "Meta (Muse subscription)"
225
+ is_subscription = True
226
+ login_label = "Sign in with Meta"
227
+
228
+ async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
229
+ return await _login_meta(interaction)
230
+
231
+ async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
232
+ return await _mint_api_key(credential["refresh"], signal)
233
+
234
+ async def to_auth(self, credential: Credential) -> ModelAuth:
235
+ return ModelAuth(api_key=credential["access"])
236
+
237
+
238
+ meta_oauth = MetaOAuth()