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,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)
|
pi_ai/auth/oauth/xai.py
ADDED
|
@@ -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()
|