pi-ai-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,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()
|
pi_ai/auth/oauth/load.py
ADDED
|
@@ -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)
|
pi_ai/auth/oauth/meta.py
ADDED
|
@@ -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()
|