@rise-so/pi-grok-build 0.1.0
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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +63 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +44 -0
- package/dist/xai-grok-build.d.ts +161 -0
- package/dist/xai-grok-build.js +391 -0
- package/package.json +61 -0
- package/src/extension.ts +82 -0
- package/src/provider.ts +74 -0
- package/src/xai-grok-build.ts +549 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// Core of the xAI Grok Build integration: model metadata, PKCE, OIDC
|
|
2
|
+
// discovery, loopback callback server, token exchange/refresh, and credential
|
|
3
|
+
// narrowing. OAuth only — API keys are deliberately unsupported in this
|
|
4
|
+
// project (the subscription token is the whole point). This module has ZERO
|
|
5
|
+
// pi-ai runtime
|
|
6
|
+
// imports so it can be shared by both the pi-ai adapter (provider.ts) and the
|
|
7
|
+
// pi extension adapter (extension.ts) — the extension loader's jiti alias
|
|
8
|
+
// mangles pi-ai subpath imports, so anything the extension reaches must stay
|
|
9
|
+
// free of them.
|
|
10
|
+
//
|
|
11
|
+
// The OAuth flow borrows the Grok CLI's public client id, scope, and fixed
|
|
12
|
+
// loopback port (56121) because xAI has no public client registration. xAI can
|
|
13
|
+
// break this flow at any time, and a concurrent Grok CLI login on the same
|
|
14
|
+
// machine collides on the port — accepted for a personal tool.
|
|
15
|
+
|
|
16
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
+
import { createServer, type Server } from "node:http";
|
|
18
|
+
// Type-only: erased at runtime, so the extension path never resolves pi-ai.
|
|
19
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
20
|
+
|
|
21
|
+
export const XAI_PROVIDER_ID = "xai-grok-build";
|
|
22
|
+
export const XAI_MODEL_ID = "grok-build-0.1";
|
|
23
|
+
export const XAI_BASE_URL = "https://api.x.ai/v1";
|
|
24
|
+
|
|
25
|
+
const XAI_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
|
|
26
|
+
const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
27
|
+
const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
28
|
+
const XAI_REDIRECT_HOST = "127.0.0.1";
|
|
29
|
+
const XAI_REDIRECT_PORT = 56_121;
|
|
30
|
+
const XAI_REDIRECT_PATH = "/callback";
|
|
31
|
+
const XAI_REFRESH_LEAD_MS = 5 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
const MODEL_COMPAT = {
|
|
34
|
+
sendSessionIdHeader: false,
|
|
35
|
+
supportsLongCacheRetention: false,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
// Catalog notes:
|
|
39
|
+
// - Context windows and per-Mtok pricing for grok-build-0.1 and grok-4.3 are
|
|
40
|
+
// confirmed by xAI's live GET /v1/models (see `pnpm smoke:models`). Live
|
|
41
|
+
// pricing doubles past a 200k-token threshold; pi's cost model has no
|
|
42
|
+
// long-context tier, so the base tier is recorded.
|
|
43
|
+
// - maxTokens values are third-party registry metadata, unverified against
|
|
44
|
+
// live output caps: pi-ai sends max_output_tokens only when stream options
|
|
45
|
+
// set maxTokens, which they normally don't.
|
|
46
|
+
// - grok-composer-2.5-fast does NOT appear in the live /models catalog but
|
|
47
|
+
// accepts /responses calls with a subscription OAuth token. Context/output
|
|
48
|
+
// metadata comes from third-party registries; pricing was supplied by the
|
|
49
|
+
// maintainer (not machine-verifiable against /models since it's unlisted).
|
|
50
|
+
//
|
|
51
|
+
// The credential also sees media models (grok-imagine-image,
|
|
52
|
+
// grok-imagine-image-quality, grok-imagine-video, grok-imagine-video-1.5).
|
|
53
|
+
// They use xAI's /images and /videos endpoints, not /responses, so they are
|
|
54
|
+
// deliberately NOT registered here — see README "Media models".
|
|
55
|
+
|
|
56
|
+
export const GROK_BUILD_MODEL = {
|
|
57
|
+
id: XAI_MODEL_ID,
|
|
58
|
+
name: "Grok Build 0.1",
|
|
59
|
+
api: "openai-responses",
|
|
60
|
+
provider: XAI_PROVIDER_ID,
|
|
61
|
+
baseUrl: XAI_BASE_URL,
|
|
62
|
+
reasoning: false,
|
|
63
|
+
input: ["text", "image"],
|
|
64
|
+
cost: {
|
|
65
|
+
input: 1,
|
|
66
|
+
output: 2,
|
|
67
|
+
cacheRead: 0.2,
|
|
68
|
+
cacheWrite: 0,
|
|
69
|
+
},
|
|
70
|
+
contextWindow: 256_000,
|
|
71
|
+
maxTokens: 256_000,
|
|
72
|
+
compat: MODEL_COMPAT,
|
|
73
|
+
} satisfies Model<"openai-responses">;
|
|
74
|
+
|
|
75
|
+
export const GROK_43_MODEL = {
|
|
76
|
+
id: "grok-4.3",
|
|
77
|
+
name: "Grok 4.3",
|
|
78
|
+
api: "openai-responses",
|
|
79
|
+
provider: XAI_PROVIDER_ID,
|
|
80
|
+
baseUrl: XAI_BASE_URL,
|
|
81
|
+
reasoning: true,
|
|
82
|
+
input: ["text", "image"],
|
|
83
|
+
cost: {
|
|
84
|
+
input: 1.25,
|
|
85
|
+
output: 2.5,
|
|
86
|
+
cacheRead: 0.2,
|
|
87
|
+
cacheWrite: 0,
|
|
88
|
+
},
|
|
89
|
+
contextWindow: 1_000_000,
|
|
90
|
+
maxTokens: 65_536,
|
|
91
|
+
compat: MODEL_COMPAT,
|
|
92
|
+
} satisfies Model<"openai-responses">;
|
|
93
|
+
|
|
94
|
+
export const GROK_COMPOSER_MODEL = {
|
|
95
|
+
id: "grok-composer-2.5-fast",
|
|
96
|
+
name: "Grok Composer 2.5 Fast",
|
|
97
|
+
api: "openai-responses",
|
|
98
|
+
provider: XAI_PROVIDER_ID,
|
|
99
|
+
baseUrl: XAI_BASE_URL,
|
|
100
|
+
reasoning: false,
|
|
101
|
+
input: ["text"],
|
|
102
|
+
cost: {
|
|
103
|
+
input: 3,
|
|
104
|
+
output: 15,
|
|
105
|
+
cacheRead: 0.5,
|
|
106
|
+
cacheWrite: 0,
|
|
107
|
+
},
|
|
108
|
+
contextWindow: 200_000,
|
|
109
|
+
maxTokens: 32_768,
|
|
110
|
+
compat: MODEL_COMPAT,
|
|
111
|
+
} satisfies Model<"openai-responses">;
|
|
112
|
+
|
|
113
|
+
export const GROK_MODELS = [GROK_BUILD_MODEL, GROK_43_MODEL, GROK_COMPOSER_MODEL] as const;
|
|
114
|
+
|
|
115
|
+
export type XaiOptions = {
|
|
116
|
+
/**
|
|
117
|
+
* Authorization-URL `referrer` param identifying the host application.
|
|
118
|
+
* Defaults to "pi-grok-build"; the pi extension adapter passes "pi".
|
|
119
|
+
*/
|
|
120
|
+
referrer?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Everything the login flow needs from its host. Deliberately smaller than
|
|
125
|
+
* either pi surface: xAI's flow never prompts, only notifies.
|
|
126
|
+
*/
|
|
127
|
+
export type XaiLoginHost = {
|
|
128
|
+
authUrl(url: string, instructions: string): void;
|
|
129
|
+
progress(message: string): void;
|
|
130
|
+
signal?: AbortSignal;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `type`, not `interface` — needs an implicit index signature to be assignable
|
|
135
|
+
* to pi's OAuthCredential / OAuthCredentials (`[key: string]: unknown`).
|
|
136
|
+
* Exactly these four fields, nothing more: pi coding-agent's refresh path does
|
|
137
|
+
* a FULL REPLACE of the stored credential, so anything refresh omits is gone
|
|
138
|
+
* forever. Identity fields (idToken/email/subject) are deliberately not
|
|
139
|
+
* persisted — nothing reads them and the refresh grant may omit id_token.
|
|
140
|
+
*/
|
|
141
|
+
export type XaiOAuthCredential = {
|
|
142
|
+
access: string;
|
|
143
|
+
refresh: string;
|
|
144
|
+
expires: number;
|
|
145
|
+
tokenEndpoint: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** pi hands back `Record<string, unknown>`. Narrow at the boundary; fail fast. */
|
|
149
|
+
export function requireXaiOAuthCredential(value: Record<string, unknown>): XaiOAuthCredential {
|
|
150
|
+
const access = stringValue(value["access"]);
|
|
151
|
+
const refresh = stringValue(value["refresh"]);
|
|
152
|
+
const tokenEndpoint = stringValue(value["tokenEndpoint"]);
|
|
153
|
+
const expires = value["expires"];
|
|
154
|
+
if (
|
|
155
|
+
access === undefined ||
|
|
156
|
+
refresh === undefined ||
|
|
157
|
+
tokenEndpoint === undefined ||
|
|
158
|
+
typeof expires !== "number"
|
|
159
|
+
) {
|
|
160
|
+
throw new Error("Stored xAI credential is incomplete; log in again");
|
|
161
|
+
}
|
|
162
|
+
return { access, refresh, expires, tokenEndpoint };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type XaiDiscovery = {
|
|
166
|
+
authorizationEndpoint: string;
|
|
167
|
+
tokenEndpoint: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
type XaiCallbackResult =
|
|
171
|
+
| { type: "success"; code: string; state: string }
|
|
172
|
+
| { type: "error"; error: string; state: string };
|
|
173
|
+
|
|
174
|
+
type XaiCallbackServer = {
|
|
175
|
+
redirectUri: string;
|
|
176
|
+
close: () => void;
|
|
177
|
+
waitForCallback: () => Promise<XaiCallbackResult>;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type XaiTokenData = {
|
|
181
|
+
accessToken: string;
|
|
182
|
+
refreshToken?: string;
|
|
183
|
+
expiresIn: number;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export async function loginXai(
|
|
187
|
+
host: XaiLoginHost,
|
|
188
|
+
opts: XaiOptions = {},
|
|
189
|
+
): Promise<XaiOAuthCredential> {
|
|
190
|
+
const pkce = generatePkce();
|
|
191
|
+
const state = randomToken();
|
|
192
|
+
const nonce = randomToken();
|
|
193
|
+
|
|
194
|
+
const discovery = await discoverXaiOAuth(host.signal);
|
|
195
|
+
const callbackServer = await startXaiCallbackServer(state);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const authUrl = buildAuthorizeUrl({
|
|
199
|
+
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
200
|
+
redirectUri: callbackServer.redirectUri,
|
|
201
|
+
codeChallenge: pkce.challenge,
|
|
202
|
+
state,
|
|
203
|
+
nonce,
|
|
204
|
+
referrer: opts.referrer ?? "pi-grok-build",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
host.authUrl(
|
|
208
|
+
authUrl,
|
|
209
|
+
"Complete xAI login in your browser. This local process is waiting for the OAuth callback.",
|
|
210
|
+
);
|
|
211
|
+
host.progress("Waiting for xAI authentication callback...");
|
|
212
|
+
|
|
213
|
+
// The callback server only settles on requests carrying our state, so no
|
|
214
|
+
// re-check is needed here.
|
|
215
|
+
const result = await waitForCallbackOrAbort(callbackServer.waitForCallback(), host.signal);
|
|
216
|
+
if (result.type === "error") {
|
|
217
|
+
throw new Error(`xAI authentication failed: ${result.error}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
host.progress("Exchanging xAI authorization code...");
|
|
221
|
+
const token = await exchangeXaiCode(
|
|
222
|
+
result.code,
|
|
223
|
+
callbackServer.redirectUri,
|
|
224
|
+
pkce.verifier,
|
|
225
|
+
discovery.tokenEndpoint,
|
|
226
|
+
host.signal,
|
|
227
|
+
);
|
|
228
|
+
return tokenToCredential(token, discovery.tokenEndpoint);
|
|
229
|
+
} finally {
|
|
230
|
+
callbackServer.close();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function refreshXai(credential: XaiOAuthCredential): Promise<XaiOAuthCredential> {
|
|
235
|
+
const token = await postTokenForm(credential.tokenEndpoint, {
|
|
236
|
+
grant_type: "refresh_token",
|
|
237
|
+
client_id: XAI_CLIENT_ID,
|
|
238
|
+
refresh_token: credential.refresh,
|
|
239
|
+
});
|
|
240
|
+
return tokenToCredential(token, credential.tokenEndpoint, credential.refresh);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function discoverXaiOAuth(signal?: AbortSignal): Promise<XaiDiscovery> {
|
|
244
|
+
const response = await fetch(XAI_DISCOVERY_URL, {
|
|
245
|
+
headers: { Accept: "application/json" },
|
|
246
|
+
...(signal !== undefined ? { signal } : {}),
|
|
247
|
+
});
|
|
248
|
+
const text = await response.text();
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
throw new Error(`xAI discovery failed (${response.status}): ${text || response.statusText}`);
|
|
251
|
+
}
|
|
252
|
+
const body = parseJsonObject(text, "xAI discovery response");
|
|
253
|
+
const authorizationEndpoint = validateXaiOAuthEndpoint(
|
|
254
|
+
body["authorization_endpoint"],
|
|
255
|
+
"authorization_endpoint",
|
|
256
|
+
);
|
|
257
|
+
const tokenEndpoint = validateXaiOAuthEndpoint(body["token_endpoint"], "token_endpoint");
|
|
258
|
+
return { authorizationEndpoint, tokenEndpoint };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildAuthorizeUrl(input: {
|
|
262
|
+
authorizationEndpoint: string;
|
|
263
|
+
redirectUri: string;
|
|
264
|
+
codeChallenge: string;
|
|
265
|
+
state: string;
|
|
266
|
+
nonce: string;
|
|
267
|
+
referrer: string;
|
|
268
|
+
}): string {
|
|
269
|
+
const url = new URL(input.authorizationEndpoint);
|
|
270
|
+
url.searchParams.set("response_type", "code");
|
|
271
|
+
url.searchParams.set("client_id", XAI_CLIENT_ID);
|
|
272
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
273
|
+
url.searchParams.set("scope", XAI_SCOPE);
|
|
274
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
275
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
276
|
+
url.searchParams.set("state", input.state);
|
|
277
|
+
url.searchParams.set("nonce", input.nonce);
|
|
278
|
+
url.searchParams.set("plan", "generic");
|
|
279
|
+
url.searchParams.set("referrer", input.referrer);
|
|
280
|
+
return url.toString();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function startXaiCallbackServer(expectedState: string): Promise<XaiCallbackServer> {
|
|
284
|
+
const redirectUri = `http://${XAI_REDIRECT_HOST}:${XAI_REDIRECT_PORT}${XAI_REDIRECT_PATH}`;
|
|
285
|
+
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
let settled = false;
|
|
288
|
+
let settleWait: ((value: XaiCallbackResult) => void) | undefined;
|
|
289
|
+
|
|
290
|
+
const waitPromise = new Promise<XaiCallbackResult>((resolveWait) => {
|
|
291
|
+
settleWait = (value) => {
|
|
292
|
+
if (settled) return;
|
|
293
|
+
settled = true;
|
|
294
|
+
resolveWait(value);
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const server = createServer((req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const url = new URL(req.url ?? "", redirectUri);
|
|
301
|
+
if (url.pathname !== XAI_REDIRECT_PATH) {
|
|
302
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
303
|
+
res.end(oauthPage("Authentication failed", "Callback route not found."));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const error = stringValue(url.searchParams.get("error"));
|
|
308
|
+
const state = stringValue(url.searchParams.get("state"));
|
|
309
|
+
const code = stringValue(url.searchParams.get("code"));
|
|
310
|
+
|
|
311
|
+
// Only a request carrying the expected state may settle the wait: the
|
|
312
|
+
// fixed loopback port is shared with the Grok CLI, so a stray request
|
|
313
|
+
// or stale browser tab must not kill a legitimate login in flight.
|
|
314
|
+
// OAuth requires xAI to echo our state on both success and error
|
|
315
|
+
// redirects, so the real callback always passes this gate.
|
|
316
|
+
if (state !== expectedState) {
|
|
317
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
318
|
+
res.end(oauthPage("Authentication failed", "OAuth state mismatch."));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (error !== undefined) {
|
|
323
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
324
|
+
res.end(oauthPage("Authentication failed", `xAI returned: ${error}`));
|
|
325
|
+
settleWait?.({ type: "error", error, state });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (code === undefined) {
|
|
330
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
331
|
+
res.end(oauthPage("Authentication failed", "Missing authorization code."));
|
|
332
|
+
settleWait?.({ type: "error", error: "missing authorization code", state });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
337
|
+
res.end(
|
|
338
|
+
oauthPage(
|
|
339
|
+
"Authentication successful",
|
|
340
|
+
"xAI authentication completed. You can close this window.",
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
settleWait?.({ type: "success", code, state });
|
|
344
|
+
} catch {
|
|
345
|
+
// An unparseable request cannot be our callback; answer it and keep
|
|
346
|
+
// waiting. The login timeout still bounds the wait.
|
|
347
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
348
|
+
res.end(
|
|
349
|
+
oauthPage("Authentication failed", "Internal error while processing OAuth callback."),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const onListenError = (error: NodeJS.ErrnoException) => {
|
|
355
|
+
if (error.code === "EADDRINUSE") {
|
|
356
|
+
reject(
|
|
357
|
+
new Error(
|
|
358
|
+
`Port ${XAI_REDIRECT_PORT} is already in use — likely a concurrent Grok CLI ` +
|
|
359
|
+
"login (the OAuth client shares its fixed loopback port). Close it and retry.",
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
reject(error);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
server.once("error", onListenError);
|
|
368
|
+
server.listen(XAI_REDIRECT_PORT, XAI_REDIRECT_HOST, () => {
|
|
369
|
+
server.off("error", onListenError);
|
|
370
|
+
resolve({
|
|
371
|
+
redirectUri,
|
|
372
|
+
close: () => closeServer(server),
|
|
373
|
+
waitForCallback: () => waitPromise,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function waitForCallbackOrAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
380
|
+
const timeoutSignal = AbortSignal.timeout(5 * 60 * 1000);
|
|
381
|
+
const combinedSignal =
|
|
382
|
+
signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
383
|
+
|
|
384
|
+
if (combinedSignal.aborted) throw new Error("xAI OAuth login cancelled");
|
|
385
|
+
|
|
386
|
+
return Promise.race([
|
|
387
|
+
promise,
|
|
388
|
+
new Promise<T>((_, reject) => {
|
|
389
|
+
combinedSignal.addEventListener(
|
|
390
|
+
"abort",
|
|
391
|
+
() => reject(new Error("xAI OAuth login cancelled or timed out")),
|
|
392
|
+
{ once: true },
|
|
393
|
+
);
|
|
394
|
+
}),
|
|
395
|
+
]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function exchangeXaiCode(
|
|
399
|
+
code: string,
|
|
400
|
+
redirectUri: string,
|
|
401
|
+
verifier: string,
|
|
402
|
+
tokenEndpoint: string,
|
|
403
|
+
signal?: AbortSignal,
|
|
404
|
+
): Promise<XaiTokenData> {
|
|
405
|
+
return postTokenForm(
|
|
406
|
+
tokenEndpoint,
|
|
407
|
+
{
|
|
408
|
+
grant_type: "authorization_code",
|
|
409
|
+
code,
|
|
410
|
+
redirect_uri: redirectUri,
|
|
411
|
+
client_id: XAI_CLIENT_ID,
|
|
412
|
+
code_verifier: verifier,
|
|
413
|
+
},
|
|
414
|
+
signal,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function postTokenForm(
|
|
419
|
+
tokenEndpoint: string,
|
|
420
|
+
fields: Record<string, string>,
|
|
421
|
+
signal?: AbortSignal,
|
|
422
|
+
): Promise<XaiTokenData> {
|
|
423
|
+
const endpoint = validateXaiOAuthEndpoint(tokenEndpoint, "token_endpoint");
|
|
424
|
+
const response = await fetch(endpoint, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
427
|
+
body: new URLSearchParams(fields),
|
|
428
|
+
...(signal !== undefined ? { signal } : {}),
|
|
429
|
+
});
|
|
430
|
+
const text = await response.text();
|
|
431
|
+
if (!response.ok) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`xAI token request failed (${response.status}): ${text || response.statusText}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return parseTokenResponse(text);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function parseTokenResponse(text: string): XaiTokenData {
|
|
440
|
+
const body = parseJsonObject(text, "xAI token response");
|
|
441
|
+
const accessToken = requiredString(body["access_token"], "access_token");
|
|
442
|
+
const refreshToken = stringValue(body["refresh_token"]);
|
|
443
|
+
const expiresIn = numberValue(body["expires_in"]);
|
|
444
|
+
if (expiresIn === undefined) {
|
|
445
|
+
throw new Error("xAI token response missing expires_in");
|
|
446
|
+
}
|
|
447
|
+
assertBearer(stringValue(body["token_type"]));
|
|
448
|
+
return {
|
|
449
|
+
accessToken,
|
|
450
|
+
...(refreshToken !== undefined ? { refreshToken } : {}),
|
|
451
|
+
expiresIn,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// RFC 6749 §7.1: token_type values are case-insensitive; xAI sends "Bearer".
|
|
456
|
+
function assertBearer(tokenType: string | undefined): void {
|
|
457
|
+
if (tokenType !== undefined && tokenType.toLowerCase() !== "bearer") {
|
|
458
|
+
throw new Error(`xAI returned unsupported token_type: ${tokenType}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function tokenToCredential(
|
|
463
|
+
token: XaiTokenData,
|
|
464
|
+
tokenEndpoint: string,
|
|
465
|
+
fallbackRefreshToken?: string,
|
|
466
|
+
): XaiOAuthCredential {
|
|
467
|
+
const refresh = token.refreshToken ?? fallbackRefreshToken;
|
|
468
|
+
if (refresh === undefined) {
|
|
469
|
+
throw new Error("xAI token response missing refresh_token");
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
access: token.accessToken,
|
|
473
|
+
refresh,
|
|
474
|
+
expires: Date.now() + token.expiresIn * 1000 - XAI_REFRESH_LEAD_MS,
|
|
475
|
+
tokenEndpoint,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function generatePkce(): { verifier: string; challenge: string } {
|
|
480
|
+
const verifier = randomBytes(96).toString("base64url");
|
|
481
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
482
|
+
return { verifier, challenge };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function randomToken(): string {
|
|
486
|
+
return randomBytes(16).toString("hex");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function validateXaiOAuthEndpoint(value: unknown, field: string): string {
|
|
490
|
+
const endpoint = requiredString(value, field);
|
|
491
|
+
const url = new URL(endpoint);
|
|
492
|
+
if (url.protocol !== "https:") {
|
|
493
|
+
throw new Error(`xAI discovery ${field} must use https`);
|
|
494
|
+
}
|
|
495
|
+
const host = url.hostname.toLowerCase();
|
|
496
|
+
if (host !== "x.ai" && !host.endsWith(".x.ai")) {
|
|
497
|
+
throw new Error(`xAI discovery ${field} host is not on x.ai`);
|
|
498
|
+
}
|
|
499
|
+
return endpoint;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function parseJsonObject(text: string, label: string): Record<string, unknown> {
|
|
503
|
+
const parsed: unknown = JSON.parse(text);
|
|
504
|
+
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`);
|
|
505
|
+
return parsed;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function requiredString(value: unknown, field: string): string {
|
|
509
|
+
const result = stringValue(value);
|
|
510
|
+
if (result === undefined) throw new Error(`xAI response missing ${field}`);
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function numberValue(value: unknown): number | undefined {
|
|
515
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function stringValue(value: unknown): string | undefined {
|
|
519
|
+
if (typeof value !== "string") return undefined;
|
|
520
|
+
const trimmed = value.trim();
|
|
521
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function oauthPage(title: string, message: string): string {
|
|
525
|
+
const escapedTitle = escapeHtml(title);
|
|
526
|
+
const escapedMessage = escapeHtml(message);
|
|
527
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escapedTitle}</title></head><body><main><h1>${escapedTitle}</h1><p>${escapedMessage}</p></main></body></html>`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function escapeHtml(value: string): string {
|
|
531
|
+
return value
|
|
532
|
+
.replaceAll("&", "&")
|
|
533
|
+
.replaceAll("<", "<")
|
|
534
|
+
.replaceAll(">", ">")
|
|
535
|
+
.replaceAll('"', """)
|
|
536
|
+
.replaceAll("'", "'");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function closeServer(server: Server): void {
|
|
540
|
+
server.close((error) => {
|
|
541
|
+
if (error) {
|
|
542
|
+
// Nothing actionable for the caller: the login flow has already settled.
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
548
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
549
|
+
}
|