@oh-my-pi/pi-ai 16.3.13 → 16.3.15
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.
- package/CHANGELOG.md +33 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +10 -4
- package/dist/types/providers/openai-responses-wire.d.ts +7 -0
- package/dist/types/providers/openai-shared.d.ts +18 -5
- package/dist/types/registry/oauth/device-code.d.ts +25 -0
- package/dist/types/registry/oauth/index.d.ts +1 -25
- package/dist/types/registry/oauth/xai-oauth.d.ts +9 -25
- package/dist/types/registry/registry.d.ts +0 -1
- package/dist/types/registry/xai-oauth.d.ts +0 -1
- package/dist/types/types.d.ts +3 -3
- package/package.json +4 -4
- package/src/auth-gateway/server.ts +2 -2
- package/src/auth-storage.ts +104 -40
- package/src/providers/azure-openai-responses.ts +2 -2
- package/src/providers/openai-codex/request-transformer.ts +61 -9
- package/src/providers/openai-codex-responses.ts +7 -7
- package/src/providers/openai-completions.ts +9 -1
- package/src/providers/openai-responses-wire.ts +7 -0
- package/src/providers/openai-responses.ts +10 -3
- package/src/providers/openai-shared.ts +30 -13
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +191 -80
- package/src/registry/oauth/device-code.ts +92 -0
- package/src/registry/oauth/index.ts +1 -91
- package/src/registry/oauth/xai-oauth.ts +231 -191
- package/src/registry/xai-oauth.ts +0 -1
- package/src/types.ts +3 -3
|
@@ -1,31 +1,22 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Device authorization and token refresh adapted from NousResearch/hermes-agent (MIT).
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* xAI Grok
|
|
4
|
+
* xAI Grok OAuth device authorization flow.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* chat, Grok Imagine image generation, and Grok Voice TTS via subsequent
|
|
9
|
-
* commits. Endpoint discovery is hardened against MITM via
|
|
10
|
-
* {@link validateXAIEndpoint}: any non-HTTPS or non-`x.ai`/`*.x.ai` host is
|
|
11
|
-
* rejected on every call site, not just the first.
|
|
6
|
+
* Requests an RFC 8628 device code, opens xAI's verification page, and polls
|
|
7
|
+
* the discovered token endpoint until the user approves the login.
|
|
12
8
|
*/
|
|
13
9
|
|
|
14
10
|
import * as AIError from "../../error";
|
|
15
11
|
import type { FetchImpl } from "../../types";
|
|
16
|
-
import {
|
|
17
|
-
import { generatePKCE } from "./pkce";
|
|
12
|
+
import { type OAuthDeviceCodePollResult, pollOAuthDeviceCodeFlow } from "./device-code";
|
|
18
13
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
19
14
|
|
|
20
|
-
// Hermes hermes_cli/auth.py L93-111
|
|
21
15
|
const XAI_OAUTH_ISSUER = "https://auth.x.ai";
|
|
22
16
|
const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
|
|
17
|
+
const XAI_OAUTH_DEVICE_CODE_URL = `${XAI_OAUTH_ISSUER}/oauth2/device/code`;
|
|
23
18
|
const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
24
19
|
const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
25
|
-
const XAI_OAUTH_REDIRECT_HOST = "127.0.0.1";
|
|
26
|
-
const XAI_OAUTH_REDIRECT_PORT = 56121;
|
|
27
|
-
const XAI_OAUTH_REDIRECT_PATH = "/callback";
|
|
28
|
-
const XAI_OAUTH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth";
|
|
29
20
|
|
|
30
21
|
// Mirrors the 5-min skew used by anthropic.ts:160 — keeps every provider on the
|
|
31
22
|
// same conservative client-side expiry window.
|
|
@@ -35,18 +26,27 @@ const DISCOVERY_TIMEOUT_MS = 15_000;
|
|
|
35
26
|
const TOKEN_REQUEST_TIMEOUT_MS = 20_000;
|
|
36
27
|
|
|
37
28
|
interface XAIOAuthDiscovery {
|
|
38
|
-
authorization_endpoint: string;
|
|
39
29
|
token_endpoint: string;
|
|
40
30
|
}
|
|
41
31
|
|
|
32
|
+
interface XAIDeviceAuthorization {
|
|
33
|
+
deviceCode: string;
|
|
34
|
+
userCode: string;
|
|
35
|
+
verificationUriComplete: string;
|
|
36
|
+
expiresInSeconds: number;
|
|
37
|
+
intervalSeconds: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null;
|
|
42
|
+
}
|
|
43
|
+
|
|
42
44
|
/**
|
|
43
|
-
* Validate an xAI OIDC
|
|
45
|
+
* Validate an xAI OIDC endpoint against its scheme and host.
|
|
44
46
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* receive every future refresh_token. Rejecting non-HTTPS or non-`x.ai` /
|
|
49
|
-
* `*.x.ai` hosts pins the cached endpoint to the xAI auth origin.
|
|
47
|
+
* The discovery response is long-lived and its token endpoint receives every
|
|
48
|
+
* future refresh token. Rejecting non-HTTPS or non-`x.ai` / `*.x.ai` hosts
|
|
49
|
+
* pins that endpoint to the xAI auth origin.
|
|
50
50
|
*
|
|
51
51
|
* @throws Error with message `Invalid xAI <field>: <url>` when the URL fails
|
|
52
52
|
* either scheme or host validation.
|
|
@@ -68,11 +68,7 @@ export function validateXAIEndpoint(url: string, field: string): string {
|
|
|
68
68
|
return url;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
* Fetch xAI's OIDC discovery document and validate both endpoints.
|
|
73
|
-
*
|
|
74
|
-
* Hermes `_xai_oauth_discovery` L3038-3084.
|
|
75
|
-
*/
|
|
71
|
+
/** Fetch xAI's OIDC discovery document and validate the token endpoint. */
|
|
76
72
|
async function xaiOAuthDiscovery(
|
|
77
73
|
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
|
|
78
74
|
fetchOverride?: FetchImpl,
|
|
@@ -111,37 +107,29 @@ async function xaiOAuthDiscovery(
|
|
|
111
107
|
{ kind: "validation", provider: "xai", cause: error },
|
|
112
108
|
);
|
|
113
109
|
}
|
|
114
|
-
if (!payload
|
|
110
|
+
if (!isRecord(payload)) {
|
|
115
111
|
throw new AIError.OAuthError("xAI OIDC discovery response was not a JSON object.", {
|
|
116
112
|
kind: "validation",
|
|
117
113
|
provider: "xai",
|
|
118
114
|
});
|
|
119
115
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const tokenEndpoint = typeof obj.token_endpoint === "string" ? obj.token_endpoint.trim() : "";
|
|
124
|
-
if (!authorizationEndpoint || !tokenEndpoint) {
|
|
125
|
-
throw new AIError.OAuthError("xAI OIDC discovery response was missing required endpoints.", {
|
|
116
|
+
const tokenEndpoint = typeof payload.token_endpoint === "string" ? payload.token_endpoint.trim() : "";
|
|
117
|
+
if (!tokenEndpoint) {
|
|
118
|
+
throw new AIError.OAuthError("xAI OIDC discovery response was missing token_endpoint.", {
|
|
126
119
|
kind: "validation",
|
|
127
120
|
provider: "xai",
|
|
128
121
|
});
|
|
129
122
|
}
|
|
130
|
-
validateXAIEndpoint(authorizationEndpoint, "authorization_endpoint");
|
|
131
123
|
validateXAIEndpoint(tokenEndpoint, "token_endpoint");
|
|
132
|
-
return {
|
|
133
|
-
authorization_endpoint: authorizationEndpoint,
|
|
134
|
-
token_endpoint: tokenEndpoint,
|
|
135
|
-
};
|
|
124
|
+
return { token_endpoint: tokenEndpoint };
|
|
136
125
|
}
|
|
137
126
|
|
|
138
127
|
/**
|
|
139
128
|
* Check whether a JWT access token is at or past its `exp` claim (with an
|
|
140
129
|
* optional refresh-skew margin).
|
|
141
130
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* non-JWTs ("no token in cache") must NOT trigger a spurious refresh.
|
|
131
|
+
* Returns `false` for malformed input because this is a refresh-trigger check,
|
|
132
|
+
* not token validation.
|
|
145
133
|
*/
|
|
146
134
|
export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0): boolean {
|
|
147
135
|
try {
|
|
@@ -151,7 +139,8 @@ export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0):
|
|
|
151
139
|
const payloadPart = parts[1];
|
|
152
140
|
if (!payloadPart) return false;
|
|
153
141
|
const decoded = Buffer.from(payloadPart, "base64url").toString("utf8");
|
|
154
|
-
const payload = JSON.parse(decoded)
|
|
142
|
+
const payload: unknown = JSON.parse(decoded);
|
|
143
|
+
if (!isRecord(payload)) return false;
|
|
155
144
|
const exp = payload.exp;
|
|
156
145
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
|
|
157
146
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -162,161 +151,232 @@ export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0):
|
|
|
162
151
|
}
|
|
163
152
|
}
|
|
164
153
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Build the xAI authorization URL.
|
|
175
|
-
*
|
|
176
|
-
* Hermes `_xai_oauth_build_authorize_url` L5286-5312. `plan=generic` opts the
|
|
177
|
-
* consent screen into xAI's generic OAuth plan tier; without it,
|
|
178
|
-
* `accounts.x.ai` rejects loopback OAuth from non-allowlisted clients.
|
|
179
|
-
* `referrer=oh-my-pi` lets xAI attribute oh-my-pi-originated logins in their
|
|
180
|
-
* OAuth server logs (Hermes uses `referrer=hermes-agent`; oh-my-pi mirrors the
|
|
181
|
-
* pattern with its own attribution string).
|
|
182
|
-
*/
|
|
183
|
-
function buildXAIAuthorizeUrl(opts: BuildXAIAuthorizeUrlOptions): string {
|
|
184
|
-
const params = new URLSearchParams({
|
|
185
|
-
response_type: "code",
|
|
186
|
-
client_id: XAI_OAUTH_CLIENT_ID,
|
|
187
|
-
redirect_uri: opts.redirectUri,
|
|
188
|
-
scope: XAI_OAUTH_SCOPE,
|
|
189
|
-
code_challenge: opts.codeChallenge,
|
|
190
|
-
code_challenge_method: "S256",
|
|
191
|
-
state: opts.state,
|
|
192
|
-
nonce: opts.nonce,
|
|
193
|
-
plan: "generic",
|
|
194
|
-
referrer: "oh-my-pi",
|
|
195
|
-
});
|
|
196
|
-
return `${opts.authorizationEndpoint}?${params.toString()}`;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
|
|
201
|
-
*/
|
|
202
|
-
export class XAIOAuthFlow extends OAuthCallbackFlow {
|
|
203
|
-
#verifier: string = "";
|
|
204
|
-
#fetch: FetchImpl;
|
|
205
|
-
|
|
206
|
-
constructor(ctrl: OAuthController) {
|
|
207
|
-
super(ctrl, {
|
|
208
|
-
preferredPort: XAI_OAUTH_REDIRECT_PORT,
|
|
209
|
-
callbackPath: XAI_OAUTH_REDIRECT_PATH,
|
|
210
|
-
callbackHostname: XAI_OAUTH_REDIRECT_HOST,
|
|
211
|
-
redirectUri: `http://${XAI_OAUTH_REDIRECT_HOST}:${XAI_OAUTH_REDIRECT_PORT}${XAI_OAUTH_REDIRECT_PATH}`,
|
|
212
|
-
manualInputOnly: true,
|
|
213
|
-
} satisfies OAuthCallbackFlowOptions);
|
|
214
|
-
this.#fetch = ctrl.fetch ?? fetch;
|
|
154
|
+
function parseXAIDeviceAuthorization(payload: unknown): XAIDeviceAuthorization {
|
|
155
|
+
if (!isRecord(payload)) {
|
|
156
|
+
throw new AIError.OAuthError("xAI device-code response was not a JSON object.", {
|
|
157
|
+
kind: "validation",
|
|
158
|
+
provider: "xai",
|
|
159
|
+
});
|
|
215
160
|
}
|
|
216
161
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
162
|
+
const deviceCode = typeof payload.device_code === "string" ? payload.device_code.trim() : "";
|
|
163
|
+
const userCode = typeof payload.user_code === "string" ? payload.user_code.trim() : "";
|
|
164
|
+
const verificationUri = typeof payload.verification_uri === "string" ? payload.verification_uri.trim() : "";
|
|
165
|
+
const verificationUriComplete =
|
|
166
|
+
typeof payload.verification_uri_complete === "string" ? payload.verification_uri_complete.trim() : "";
|
|
167
|
+
const expiresInSeconds = payload.expires_in;
|
|
168
|
+
const intervalSeconds = payload.interval;
|
|
169
|
+
if (
|
|
170
|
+
!deviceCode ||
|
|
171
|
+
!userCode ||
|
|
172
|
+
!verificationUri ||
|
|
173
|
+
!verificationUriComplete ||
|
|
174
|
+
typeof expiresInSeconds !== "number" ||
|
|
175
|
+
!Number.isFinite(expiresInSeconds) ||
|
|
176
|
+
expiresInSeconds <= 0 ||
|
|
177
|
+
typeof intervalSeconds !== "number" ||
|
|
178
|
+
!Number.isFinite(intervalSeconds) ||
|
|
179
|
+
intervalSeconds <= 0
|
|
180
|
+
) {
|
|
181
|
+
throw new AIError.OAuthError("xAI device-code response missing or invalid required fields.", {
|
|
182
|
+
kind: "validation",
|
|
183
|
+
provider: "xai",
|
|
229
184
|
});
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
url,
|
|
233
|
-
instructions: `Complete login in your browser for xAI Grok (SuperGrok or X Premium+). Docs: ${XAI_OAUTH_DOCS_URL}`,
|
|
234
|
-
};
|
|
235
185
|
}
|
|
236
186
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
187
|
+
validateXAIEndpoint(verificationUri, "verification_uri");
|
|
188
|
+
validateXAIEndpoint(verificationUriComplete, "verification_uri_complete");
|
|
189
|
+
return {
|
|
190
|
+
deviceCode,
|
|
191
|
+
userCode,
|
|
192
|
+
verificationUriComplete,
|
|
193
|
+
expiresInSeconds,
|
|
194
|
+
intervalSeconds,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
240
197
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
code_verifier: this.#verifier,
|
|
198
|
+
function parseXAITokenResponse(payload: unknown, label: string, refreshTokenFallback?: string): OAuthCredentials {
|
|
199
|
+
if (!isRecord(payload)) {
|
|
200
|
+
throw new AIError.OAuthError(`${label} was not a JSON object`, {
|
|
201
|
+
kind: "validation",
|
|
202
|
+
provider: "xai",
|
|
247
203
|
});
|
|
204
|
+
}
|
|
205
|
+
const accessToken = typeof payload.access_token === "string" ? payload.access_token : "";
|
|
206
|
+
const responseRefreshToken = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
|
|
207
|
+
const refreshToken = responseRefreshToken || refreshTokenFallback || "";
|
|
208
|
+
const expiresInSeconds = payload.expires_in;
|
|
209
|
+
if (!accessToken) {
|
|
210
|
+
throw new AIError.OAuthError(`${label} missing access_token`, {
|
|
211
|
+
kind: "validation",
|
|
212
|
+
provider: "xai",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (!refreshToken) {
|
|
216
|
+
throw new AIError.OAuthError(`${label} missing refresh_token`, {
|
|
217
|
+
kind: "validation",
|
|
218
|
+
provider: "xai",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (typeof expiresInSeconds !== "number" || !Number.isFinite(expiresInSeconds)) {
|
|
222
|
+
throw new AIError.OAuthError(`${label} missing expires_in`, {
|
|
223
|
+
kind: "validation",
|
|
224
|
+
provider: "xai",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
access: accessToken,
|
|
229
|
+
refresh: refreshToken,
|
|
230
|
+
expires: Date.now() + expiresInSeconds * 1000 - ACCESS_TOKEN_CLIENT_SKEW_MS,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
248
233
|
|
|
249
|
-
|
|
234
|
+
async function requestXAIDeviceAuthorization(
|
|
235
|
+
fetchImpl: FetchImpl,
|
|
236
|
+
signal?: AbortSignal,
|
|
237
|
+
): Promise<XAIDeviceAuthorization> {
|
|
238
|
+
let response: Response;
|
|
239
|
+
try {
|
|
240
|
+
const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
241
|
+
response = await fetchImpl(XAI_OAUTH_DEVICE_CODE_URL, {
|
|
250
242
|
method: "POST",
|
|
251
243
|
headers: {
|
|
252
244
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
253
245
|
Accept: "application/json",
|
|
254
246
|
},
|
|
255
|
-
body
|
|
256
|
-
|
|
247
|
+
body: new URLSearchParams({
|
|
248
|
+
client_id: XAI_OAUTH_CLIENT_ID,
|
|
249
|
+
scope: XAI_OAUTH_SCOPE,
|
|
250
|
+
}),
|
|
251
|
+
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
|
257
252
|
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (signal?.aborted) throw new AIError.LoginCancelledError();
|
|
255
|
+
throw new AIError.OAuthError(
|
|
256
|
+
`xAI device-code request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
257
|
+
{ kind: "device-auth", provider: "xai", cause: error },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
258
260
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
detail = (await response.text()).trim();
|
|
263
|
-
} catch {
|
|
264
|
-
// Ignore body-read failures; the status code is the diagnostic.
|
|
265
|
-
}
|
|
266
|
-
throw new AIError.OAuthError(`xAI token exchange failed: ${response.status}${detail ? ` ${detail}` : ""}`, {
|
|
267
|
-
kind: "token-exchange",
|
|
268
|
-
provider: "xai",
|
|
269
|
-
status: response.status,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
let tokenData: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
let detail = "";
|
|
274
263
|
try {
|
|
275
|
-
|
|
276
|
-
} catch
|
|
277
|
-
|
|
278
|
-
`xAI token exchange returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
279
|
-
{ kind: "validation", provider: "xai", cause: error },
|
|
280
|
-
);
|
|
264
|
+
detail = (await response.text()).trim();
|
|
265
|
+
} catch {
|
|
266
|
+
// Ignore body-read failures; the status code is the diagnostic.
|
|
281
267
|
}
|
|
268
|
+
throw new AIError.OAuthError(`xAI device-code request failed: ${response.status}${detail ? ` ${detail}` : ""}`, {
|
|
269
|
+
kind: "device-auth",
|
|
270
|
+
provider: "xai",
|
|
271
|
+
status: response.status,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
282
274
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
275
|
+
let payload: unknown;
|
|
276
|
+
try {
|
|
277
|
+
payload = await response.json();
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw new AIError.OAuthError(
|
|
280
|
+
`xAI device-code response returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
281
|
+
{ kind: "validation", provider: "xai", cause: error },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return parseXAIDeviceAuthorization(payload);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function pollXAIDeviceToken(
|
|
288
|
+
tokenEndpoint: string,
|
|
289
|
+
deviceCode: string,
|
|
290
|
+
fetchImpl: FetchImpl,
|
|
291
|
+
signal?: AbortSignal,
|
|
292
|
+
): Promise<OAuthDeviceCodePollResult<OAuthCredentials>> {
|
|
293
|
+
let response: Response;
|
|
294
|
+
try {
|
|
295
|
+
const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
296
|
+
response = await fetchImpl(tokenEndpoint, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: {
|
|
299
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
300
|
+
Accept: "application/json",
|
|
301
|
+
},
|
|
302
|
+
body: new URLSearchParams({
|
|
303
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
304
|
+
client_id: XAI_OAUTH_CLIENT_ID,
|
|
305
|
+
device_code: deviceCode,
|
|
306
|
+
}),
|
|
307
|
+
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (signal?.aborted) throw new AIError.LoginCancelledError();
|
|
311
|
+
throw new AIError.OAuthError(
|
|
312
|
+
`xAI device-code token polling failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
313
|
+
{ kind: "polling", provider: "xai", cause: error },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
301
316
|
|
|
317
|
+
let payload: unknown;
|
|
318
|
+
try {
|
|
319
|
+
payload = await response.json();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw new AIError.OAuthError(
|
|
322
|
+
`xAI device-code token polling returned invalid JSON: ${
|
|
323
|
+
error instanceof Error ? error.message : String(error)
|
|
324
|
+
}`,
|
|
325
|
+
{ kind: "polling", provider: "xai", status: response.status, cause: error },
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (response.ok) {
|
|
302
330
|
return {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
expires: Date.now() + tokenData.expires_in * 1000 - ACCESS_TOKEN_CLIENT_SKEW_MS,
|
|
331
|
+
status: "complete",
|
|
332
|
+
value: parseXAITokenResponse(payload, "xAI device-code token response"),
|
|
306
333
|
};
|
|
307
334
|
}
|
|
335
|
+
if (!isRecord(payload)) {
|
|
336
|
+
throw new AIError.OAuthError(`xAI device-code token polling failed: ${response.status}`, {
|
|
337
|
+
kind: "polling",
|
|
338
|
+
provider: "xai",
|
|
339
|
+
status: response.status,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const errorCode = typeof payload.error === "string" ? payload.error : "";
|
|
344
|
+
if (errorCode === "authorization_pending") return { status: "pending" };
|
|
345
|
+
if (errorCode === "slow_down") return { status: "slow_down" };
|
|
346
|
+
|
|
347
|
+
const errorDescription = typeof payload.error_description === "string" ? payload.error_description : "";
|
|
348
|
+
const detail = errorDescription || errorCode || String(response.status);
|
|
349
|
+
throw new AIError.OAuthError(`xAI device-code token polling failed: ${detail}`, {
|
|
350
|
+
kind: "polling",
|
|
351
|
+
provider: "xai",
|
|
352
|
+
status: response.status,
|
|
353
|
+
});
|
|
308
354
|
}
|
|
309
355
|
|
|
356
|
+
/** Log in to xAI Grok with the RFC 8628 device authorization grant. */
|
|
310
357
|
export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
311
|
-
|
|
358
|
+
const fetchImpl = ctrl.fetch ?? fetch;
|
|
359
|
+
const discovery = await xaiOAuthDiscovery(DISCOVERY_TIMEOUT_MS, fetchImpl);
|
|
360
|
+
const device = await requestXAIDeviceAuthorization(fetchImpl, ctrl.signal);
|
|
361
|
+
ctrl.onAuth?.({
|
|
362
|
+
url: device.verificationUriComplete,
|
|
363
|
+
instructions: `Enter code: ${device.userCode}`,
|
|
364
|
+
});
|
|
365
|
+
ctrl.onProgress?.("Waiting for xAI device authorization...");
|
|
366
|
+
|
|
367
|
+
return pollOAuthDeviceCodeFlow({
|
|
368
|
+
poll: () => pollXAIDeviceToken(discovery.token_endpoint, device.deviceCode, fetchImpl, ctrl.signal),
|
|
369
|
+
intervalSeconds: device.intervalSeconds,
|
|
370
|
+
expiresInSeconds: device.expiresInSeconds,
|
|
371
|
+
signal: ctrl.signal,
|
|
372
|
+
});
|
|
312
373
|
}
|
|
313
374
|
|
|
314
375
|
/**
|
|
315
376
|
* Refresh an xAI OAuth access token using a stored refresh_token.
|
|
316
377
|
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* cached-but-poisoned endpoint cannot silently leak a refresh_token.
|
|
378
|
+
* Re-runs OIDC discovery and re-validates the token endpoint before sending
|
|
379
|
+
* the stored refresh token.
|
|
320
380
|
*/
|
|
321
381
|
export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?: FetchImpl): Promise<OAuthCredentials> {
|
|
322
382
|
const fetchImpl = fetchOverride ?? fetch;
|
|
@@ -357,34 +417,14 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?:
|
|
|
357
417
|
});
|
|
358
418
|
}
|
|
359
419
|
|
|
360
|
-
let
|
|
420
|
+
let payload: unknown;
|
|
361
421
|
try {
|
|
362
|
-
|
|
422
|
+
payload = await response.json();
|
|
363
423
|
} catch (error) {
|
|
364
424
|
throw new AIError.OAuthError(
|
|
365
425
|
`xAI token refresh returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
366
426
|
{ kind: "validation", provider: "xai", cause: error },
|
|
367
427
|
);
|
|
368
428
|
}
|
|
369
|
-
|
|
370
|
-
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
371
|
-
throw new AIError.OAuthError("xAI token refresh response missing access_token", {
|
|
372
|
-
kind: "validation",
|
|
373
|
-
provider: "xai",
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) {
|
|
377
|
-
throw new AIError.OAuthError("xAI token refresh response missing expires_in", {
|
|
378
|
-
kind: "validation",
|
|
379
|
-
provider: "xai",
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const newRefresh = typeof data.refresh_token === "string" && data.refresh_token ? data.refresh_token : refreshToken;
|
|
384
|
-
|
|
385
|
-
return {
|
|
386
|
-
access: data.access_token,
|
|
387
|
-
refresh: newRefresh,
|
|
388
|
-
expires: Date.now() + data.expires_in * 1000 - ACCESS_TOKEN_CLIENT_SKEW_MS,
|
|
389
|
-
};
|
|
429
|
+
return parseXAITokenResponse(payload, "xAI token refresh response", refreshToken);
|
|
390
430
|
}
|
package/src/types.ts
CHANGED
|
@@ -388,9 +388,9 @@ export interface StreamOptions {
|
|
|
388
388
|
*/
|
|
389
389
|
sessionId?: string;
|
|
390
390
|
/**
|
|
391
|
-
* Optional prompt-cache identity.
|
|
392
|
-
*
|
|
393
|
-
*
|
|
391
|
+
* Optional prompt-cache identity. OpenAI-family providers use this for
|
|
392
|
+
* `prompt_cache_key` payloads and cache-affinity headers such as
|
|
393
|
+
* `x-grok-conv-id`; when omitted, they fall back to `sessionId`.
|
|
394
394
|
*/
|
|
395
395
|
promptCacheKey?: string;
|
|
396
396
|
/**
|