@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.
@@ -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("&", "&amp;")
533
+ .replaceAll("<", "&lt;")
534
+ .replaceAll(">", "&gt;")
535
+ .replaceAll('"', "&quot;")
536
+ .replaceAll("'", "&#39;");
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
+ }