open-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,529 @@
1
+ /**
2
+ * xAI Grok OAuth 2.0 + PKCE implementation.
3
+ *
4
+ * Uses Web Crypto API (crypto.subtle) for PKCE so the extension is
5
+ * portable across Node versions and potential non-Node runtimes.
6
+ *
7
+ * xAI issuer (auth.x.ai) with Grok Build client_id; API traffic uses
8
+ * cli-chat-proxy.grok.com instead of api.x.ai.
9
+ */
10
+
11
+ import { createServer } from 'node:http';
12
+ import { XaiErrorCode, XaiOAuthError } from '../shared/errors.js';
13
+
14
+ // ─── Constants ────────────────────────────────────────────────────────────────
15
+
16
+ const DEFAULT_BASE_URL = 'https://cli-chat-proxy.grok.com/v1';
17
+ const ISSUER = 'https://auth.x.ai';
18
+ const DISCOVERY_URL = `${ISSUER}/.well-known/openid-configuration`;
19
+ const CLIENT_ID = process.env.GROK_BUILD_OAUTH_CLIENT_ID || 'b1a00492-073a-47ea-816f-4c329264a828';
20
+ const SCOPE =
21
+ process.env.GROK_BUILD_OAUTH_SCOPE ||
22
+ 'openid profile email offline_access grok-cli:access api:access';
23
+ const CALLBACK_HOST = process.env.GROK_BUILD_CALLBACK_HOST || '127.0.0.1';
24
+ const CALLBACK_PORT = Number.parseInt(process.env.GROK_BUILD_CALLBACK_PORT || '56122', 10);
25
+ const CALLBACK_PATH = '/callback';
26
+ /** Refresh 120s before actual expiry. */
27
+ const REFRESH_SKEW_MS = 120_000;
28
+ const TOKEN_REQUEST_TIMEOUT_MS = Number.parseInt(
29
+ process.env.GROK_BUILD_TOKEN_TIMEOUT_MS || '30000',
30
+ 10,
31
+ );
32
+
33
+ // ─── Types ────────────────────────────────────────────────────────────────────
34
+
35
+ interface XaiDiscovery {
36
+ authorization_endpoint: string;
37
+ token_endpoint: string;
38
+ }
39
+
40
+ export interface XaiOAuthCredentials {
41
+ [key: string]: unknown;
42
+ refresh: string;
43
+ access: string;
44
+ expires: number;
45
+ tokenEndpoint?: string;
46
+ discovery?: XaiDiscovery;
47
+ idToken?: string;
48
+ tokenType?: string;
49
+ baseUrl?: string;
50
+ }
51
+
52
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
53
+
54
+ export function getBaseUrl(): string {
55
+ return (process.env.GROK_BUILD_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
56
+ }
57
+
58
+ function base64Url(buffer: ArrayBuffer | Uint8Array): string {
59
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
60
+ let binary = '';
61
+ for (const b of bytes) binary += String.fromCharCode(b);
62
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
63
+ }
64
+
65
+ // ─── PKCE ─────────────────────────────────────────────────────────────────────
66
+
67
+ async function generatePKCE(): Promise<{
68
+ verifier: string;
69
+ challenge: string;
70
+ }> {
71
+ const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
72
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
73
+ return { verifier, challenge: base64Url(hash) };
74
+ }
75
+
76
+ // ─── Endpoint validation ──────────────────────────────────────────────────────
77
+
78
+ function validateEndpoint(value: string, field: string): string {
79
+ let url: URL;
80
+ try {
81
+ url = new URL(value);
82
+ } catch {
83
+ throw new XaiOAuthError(
84
+ `xAI OAuth discovery returned invalid ${field}: ${value}`,
85
+ XaiErrorCode.DISCOVERY_INVALID_ORIGIN,
86
+ );
87
+ }
88
+ if (url.protocol !== 'https:') {
89
+ throw new XaiOAuthError(
90
+ `xAI OAuth ${field} must use HTTPS: ${value}`,
91
+ XaiErrorCode.DISCOVERY_INVALID_ORIGIN,
92
+ );
93
+ }
94
+ const host = url.hostname.toLowerCase();
95
+ if (
96
+ host !== 'x.ai' &&
97
+ host !== 'auth.x.ai' &&
98
+ host !== 'accounts.x.ai' &&
99
+ !host.endsWith('.x.ai')
100
+ ) {
101
+ throw new XaiOAuthError(
102
+ `Refusing non-xAI OAuth ${field}: ${value}`,
103
+ XaiErrorCode.DISCOVERY_INVALID_ORIGIN,
104
+ );
105
+ }
106
+ return url.toString();
107
+ }
108
+
109
+ // ─── OIDC Discovery ──────────────────────────────────────────────────────────
110
+
111
+ async function discover(): Promise<XaiDiscovery> {
112
+ let response: Response;
113
+ try {
114
+ response = await fetch(DISCOVERY_URL, {
115
+ headers: { Accept: 'application/json' },
116
+ signal: AbortSignal.timeout(15_000),
117
+ });
118
+ } catch (cause) {
119
+ throw new XaiOAuthError(
120
+ `xAI OIDC discovery failed: ${cause instanceof Error ? cause.message : String(cause)}`,
121
+ XaiErrorCode.DISCOVERY_FAILED,
122
+ );
123
+ }
124
+ if (!response.ok) {
125
+ throw new XaiOAuthError(
126
+ `xAI OIDC discovery returned ${response.status}`,
127
+ XaiErrorCode.DISCOVERY_FAILED,
128
+ );
129
+ }
130
+ let payload: Record<string, unknown>;
131
+ try {
132
+ payload = (await response.json()) as Record<string, unknown>;
133
+ } catch (cause) {
134
+ throw new XaiOAuthError(
135
+ `xAI OIDC discovery returned invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
136
+ XaiErrorCode.DISCOVERY_FAILED,
137
+ );
138
+ }
139
+ const authorizationEndpoint = validateEndpoint(
140
+ String(payload.authorization_endpoint ?? ''),
141
+ 'authorization_endpoint',
142
+ );
143
+ const tokenEndpoint = validateEndpoint(String(payload.token_endpoint ?? ''), 'token_endpoint');
144
+ return {
145
+ authorization_endpoint: authorizationEndpoint,
146
+ token_endpoint: tokenEndpoint,
147
+ };
148
+ }
149
+
150
+ // ─── Loopback callback server ────────────────────────────────────────────────
151
+
152
+ interface CallbackResult {
153
+ code?: string;
154
+ state?: string;
155
+ error?: string;
156
+ errorDescription?: string;
157
+ }
158
+
159
+ function startCallbackServer(): Promise<{
160
+ server: import('node:http').Server;
161
+ redirectUri: string;
162
+ waitForCallback: (timeoutMs: number) => Promise<CallbackResult>;
163
+ }> {
164
+ let settle: ((value: CallbackResult) => void) | undefined;
165
+ const callbackPromise = new Promise<CallbackResult>((resolve) => {
166
+ settle = resolve;
167
+ });
168
+
169
+ const server = createServer((req, res) => {
170
+ try {
171
+ const origin = req.headers.origin;
172
+ if (origin === 'https://accounts.x.ai' || origin === 'https://auth.x.ai') {
173
+ res.setHeader('Access-Control-Allow-Origin', origin);
174
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
175
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
176
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
177
+ res.setHeader('Vary', 'Origin');
178
+ }
179
+ if (req.method === 'OPTIONS') {
180
+ res.statusCode = 204;
181
+ res.end();
182
+ return;
183
+ }
184
+
185
+ const url = new URL(req.url ?? '/', `http://${CALLBACK_HOST}`);
186
+ if (url.pathname !== CALLBACK_PATH) {
187
+ res.statusCode = 404;
188
+ res.end('Not found');
189
+ return;
190
+ }
191
+
192
+ const result: CallbackResult = {
193
+ code: url.searchParams.get('code') ?? undefined,
194
+ state: url.searchParams.get('state') ?? undefined,
195
+ error: url.searchParams.get('error') ?? undefined,
196
+ errorDescription: url.searchParams.get('error_description') ?? undefined,
197
+ };
198
+
199
+ res.statusCode = result.error ? 400 : 200;
200
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
201
+ const html = result.error
202
+ ? '<html><body><h1>xAI authorization failed.</h1>You can close this tab.</body></html>'
203
+ : '<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>';
204
+ res.end(html);
205
+ settle?.(result);
206
+ } catch {
207
+ res.statusCode = 500;
208
+ res.end('Internal error');
209
+ }
210
+ });
211
+
212
+ const listen = (port: number) =>
213
+ new Promise<number>((resolve, reject) => {
214
+ server.once('error', reject);
215
+ server.listen(port, CALLBACK_HOST, () => {
216
+ server.removeListener('error', reject);
217
+ const addr = server.address();
218
+ resolve(typeof addr === 'object' && addr ? addr.port : port);
219
+ });
220
+ });
221
+
222
+ return (async () => {
223
+ let actualPort: number;
224
+ try {
225
+ actualPort = await listen(CALLBACK_PORT);
226
+ } catch (firstError) {
227
+ try {
228
+ actualPort = await listen(0);
229
+ } catch (secondError) {
230
+ const errorDescription = `Could not bind xAI OAuth callback server on ${CALLBACK_HOST}:${CALLBACK_PORT} or an ephemeral port: ${secondError instanceof Error ? secondError.message : String(secondError)} (initial error: ${firstError instanceof Error ? firstError.message : String(firstError)})`;
231
+ return {
232
+ server,
233
+ redirectUri: `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`,
234
+ waitForCallback: async () => ({
235
+ error: XaiErrorCode.CALLBACK_BIND_FAILED,
236
+ errorDescription,
237
+ }),
238
+ };
239
+ }
240
+ }
241
+ const redirectUri = `http://${CALLBACK_HOST}:${actualPort}${CALLBACK_PATH}`;
242
+ return {
243
+ server,
244
+ redirectUri,
245
+ waitForCallback: (timeoutMs: number) =>
246
+ Promise.race([
247
+ callbackPromise,
248
+ new Promise<CallbackResult>((resolve) =>
249
+ setTimeout(
250
+ () =>
251
+ resolve({
252
+ error: XaiErrorCode.CALLBACK_TIMEOUT,
253
+ errorDescription: 'Timed out waiting for xAI OAuth callback.',
254
+ }),
255
+ timeoutMs,
256
+ ),
257
+ ),
258
+ ]),
259
+ };
260
+ })();
261
+ }
262
+
263
+ // ─── Token exchange ───────────────────────────────────────────────────────────
264
+
265
+ async function fetchTokenResponse(
266
+ tokenEndpoint: string,
267
+ body: URLSearchParams,
268
+ errorCode: string,
269
+ label: string,
270
+ ): Promise<Response> {
271
+ const controller = new AbortController();
272
+ const timeout = setTimeout(() => controller.abort(), TOKEN_REQUEST_TIMEOUT_MS);
273
+ try {
274
+ return await fetch(tokenEndpoint, {
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/x-www-form-urlencoded',
278
+ Accept: 'application/json',
279
+ },
280
+ body,
281
+ signal: controller.signal,
282
+ });
283
+ } catch (cause) {
284
+ throw new XaiOAuthError(
285
+ `xAI ${label} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
286
+ errorCode,
287
+ );
288
+ } finally {
289
+ clearTimeout(timeout);
290
+ }
291
+ }
292
+
293
+ async function tokenResponseText(response: Response) {
294
+ try {
295
+ return await response.text();
296
+ } catch (cause) {
297
+ return `unable to read response body: ${cause instanceof Error ? cause.message : String(cause)}`;
298
+ }
299
+ }
300
+
301
+ async function tokenResponseJson(
302
+ response: Response,
303
+ errorCode: string,
304
+ label: string,
305
+ ): Promise<Record<string, unknown>> {
306
+ try {
307
+ return (await response.json()) as Record<string, unknown>;
308
+ } catch (cause) {
309
+ throw new XaiOAuthError(
310
+ `xAI ${label} returned invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
311
+ errorCode,
312
+ );
313
+ }
314
+ }
315
+
316
+ async function exchangeCode(
317
+ tokenEndpoint: string,
318
+ code: string,
319
+ redirectUri: string,
320
+ verifier: string,
321
+ ): Promise<XaiOAuthCredentials> {
322
+ const response = await fetchTokenResponse(
323
+ tokenEndpoint,
324
+ new URLSearchParams({
325
+ grant_type: 'authorization_code',
326
+ client_id: CLIENT_ID,
327
+ code,
328
+ redirect_uri: redirectUri,
329
+ code_verifier: verifier,
330
+ }),
331
+ XaiErrorCode.TOKEN_EXCHANGE_FAILED,
332
+ 'token exchange',
333
+ );
334
+ if (!response.ok) {
335
+ throw new XaiOAuthError(
336
+ `xAI token exchange failed: ${response.status} ${await tokenResponseText(response)}`,
337
+ XaiErrorCode.TOKEN_EXCHANGE_FAILED,
338
+ );
339
+ }
340
+ const payload = await tokenResponseJson(
341
+ response,
342
+ XaiErrorCode.TOKEN_EXCHANGE_FAILED,
343
+ 'token exchange',
344
+ );
345
+ const access = String(payload.access_token ?? '');
346
+ const refresh = String(payload.refresh_token ?? '');
347
+ if (!access) {
348
+ throw new XaiOAuthError(
349
+ 'xAI token exchange did not return access_token.',
350
+ XaiErrorCode.TOKEN_EXCHANGE_INVALID,
351
+ );
352
+ }
353
+ if (!refresh) {
354
+ throw new XaiOAuthError(
355
+ 'xAI token exchange did not return refresh_token.',
356
+ XaiErrorCode.TOKEN_EXCHANGE_INVALID,
357
+ );
358
+ }
359
+ const expiresIn =
360
+ typeof payload.expires_in === 'number'
361
+ ? payload.expires_in
362
+ : Number(payload.expires_in ?? 3600);
363
+ return {
364
+ access,
365
+ refresh,
366
+ expires: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS,
367
+ tokenEndpoint,
368
+ discovery: { authorization_endpoint: '', token_endpoint: tokenEndpoint },
369
+ idToken: String(payload.id_token ?? ''),
370
+ tokenType: String(payload.token_type ?? 'Bearer'),
371
+ baseUrl: getBaseUrl(),
372
+ };
373
+ }
374
+
375
+ export type OAuthCredentials = {
376
+ access: string;
377
+ refresh: string;
378
+ expires: number;
379
+ [key: string]: unknown;
380
+ };
381
+
382
+ export type OAuthLoginCallbacks = {
383
+ onAuth: (payload: { url: string; instructions: string }) => void;
384
+ onError?: (error: unknown) => void;
385
+ onSuccess?: () => void;
386
+ };
387
+
388
+ export type GrokBuildOAuthSession = {
389
+ url: string;
390
+ instructions: string;
391
+ finish: () => Promise<OAuthCredentials>;
392
+ };
393
+
394
+ /** Start loopback OAuth; call `finish` after the user completes the browser step. */
395
+ export async function beginGrokBuildOAuth(
396
+ referrer = 'open-grok-build',
397
+ ): Promise<GrokBuildOAuthSession> {
398
+ const discovery = await discover();
399
+ const { verifier, challenge } = await generatePKCE();
400
+ const state = base64Url(crypto.getRandomValues(new Uint8Array(16)));
401
+ const nonce = base64Url(crypto.getRandomValues(new Uint8Array(16)));
402
+ const callback = await startCallbackServer();
403
+
404
+ const authUrl = new URL(discovery.authorization_endpoint);
405
+ authUrl.searchParams.set('response_type', 'code');
406
+ authUrl.searchParams.set('client_id', CLIENT_ID);
407
+ authUrl.searchParams.set('redirect_uri', callback.redirectUri);
408
+ authUrl.searchParams.set('scope', SCOPE);
409
+ authUrl.searchParams.set('code_challenge', challenge);
410
+ authUrl.searchParams.set('code_challenge_method', 'S256');
411
+ authUrl.searchParams.set('state', state);
412
+ authUrl.searchParams.set('nonce', nonce);
413
+ authUrl.searchParams.set('plan', 'generic');
414
+ authUrl.searchParams.set('referrer', referrer);
415
+
416
+ return {
417
+ url: authUrl.toString(),
418
+ instructions: `Authorize xAI for Grok Build, then return to OpenCode. Callback: ${callback.redirectUri}`,
419
+ finish: async () => {
420
+ try {
421
+ const result = await callback.waitForCallback(180_000);
422
+
423
+ if (result.error) {
424
+ const code =
425
+ result.error === XaiErrorCode.CALLBACK_BIND_FAILED ||
426
+ result.error === XaiErrorCode.CALLBACK_TIMEOUT
427
+ ? result.error
428
+ : XaiErrorCode.AUTHORIZATION_FAILED;
429
+ throw new XaiOAuthError(result.errorDescription ?? result.error, code);
430
+ }
431
+ if (result.state !== state) {
432
+ throw new XaiOAuthError(
433
+ 'xAI OAuth state mismatch — possible CSRF.',
434
+ XaiErrorCode.STATE_MISMATCH,
435
+ );
436
+ }
437
+ if (!result.code) {
438
+ throw new XaiOAuthError(
439
+ 'xAI OAuth callback did not include an authorization code.',
440
+ XaiErrorCode.CODE_MISSING,
441
+ );
442
+ }
443
+
444
+ const credentials = await exchangeCode(
445
+ discovery.token_endpoint,
446
+ result.code,
447
+ callback.redirectUri,
448
+ verifier,
449
+ );
450
+ credentials.discovery = discovery;
451
+ return credentials;
452
+ } finally {
453
+ callback.server.close();
454
+ }
455
+ },
456
+ };
457
+ }
458
+
459
+ // ─── Programmatic login (browser OAuth) ─────────────────────────────────────
460
+
461
+ export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
462
+ const session = await beginGrokBuildOAuth('open-grok-build');
463
+ callbacks.onAuth({ url: session.url, instructions: session.instructions });
464
+ return session.finish();
465
+ }
466
+
467
+ // ─── Token refresh ────────────────────────────────────────────────────────────
468
+
469
+ export async function refresh(credentials: OAuthCredentials): Promise<OAuthCredentials> {
470
+ const xai = credentials as XaiOAuthCredentials;
471
+ const tokenEndpoint =
472
+ xai.tokenEndpoint || xai.discovery?.token_endpoint || (await discover()).token_endpoint;
473
+ validateEndpoint(tokenEndpoint, 'token_endpoint');
474
+
475
+ if (!credentials.refresh) {
476
+ throw new XaiOAuthError(
477
+ 'Missing refresh_token. Re-login required.',
478
+ XaiErrorCode.REFRESH_MISSING,
479
+ true,
480
+ );
481
+ }
482
+
483
+ const response = await fetchTokenResponse(
484
+ tokenEndpoint,
485
+ new URLSearchParams({
486
+ grant_type: 'refresh_token',
487
+ client_id: CLIENT_ID,
488
+ refresh_token: credentials.refresh,
489
+ }),
490
+ XaiErrorCode.REFRESH_FAILED,
491
+ 'token refresh',
492
+ );
493
+
494
+ if (!response.ok) {
495
+ const isFatal = response.status === 400 || response.status === 401 || response.status === 403;
496
+ throw new XaiOAuthError(
497
+ `xAI token refresh failed: ${response.status} ${await tokenResponseText(response)}`,
498
+ XaiErrorCode.REFRESH_FAILED,
499
+ isFatal,
500
+ );
501
+ }
502
+
503
+ const payload = await tokenResponseJson(response, XaiErrorCode.REFRESH_FAILED, 'token refresh');
504
+ const access = String(payload.access_token ?? '');
505
+ if (!access) {
506
+ throw new XaiOAuthError(
507
+ 'xAI token refresh did not return access_token.',
508
+ XaiErrorCode.REFRESH_FAILED,
509
+ true,
510
+ );
511
+ }
512
+
513
+ const refresh_new = String(payload.refresh_token ?? credentials.refresh);
514
+ const expiresIn =
515
+ typeof payload.expires_in === 'number'
516
+ ? payload.expires_in
517
+ : Number(payload.expires_in ?? 3600);
518
+
519
+ return {
520
+ ...xai,
521
+ access,
522
+ refresh: refresh_new,
523
+ expires: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS,
524
+ tokenEndpoint,
525
+ idToken: String(payload.id_token ?? xai.idToken ?? ''),
526
+ tokenType: String(payload.token_type ?? xai.tokenType ?? 'Bearer'),
527
+ baseUrl: getBaseUrl(),
528
+ };
529
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * open-grok-build — Grok Build OpenCode plugin.
3
+ */
4
+
5
+ export { OpenGrokBuildPlugin, OpenGrokBuildPlugin as default } from './opencode/plugin.js';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Model definitions for Grok Build's API.
3
+ */
4
+
5
+ // ─── Cost constants ($/M tokens) ──────────────────────────────────────────────
6
+
7
+ const COST_BUILD = { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0.2 };
8
+ const COST_COMPOSER_FAST = { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 };
9
+ const COST_43 = { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 };
10
+ const COST_420 = { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 };
11
+
12
+ // ─── Model type ───────────────────────────────────────────────────────────────
13
+
14
+ export interface GrokBuildModelConfig {
15
+ id: string;
16
+ name: string;
17
+ reasoning: boolean;
18
+ input: ('text' | 'image')[];
19
+ cost: {
20
+ input: number;
21
+ output: number;
22
+ cacheRead: number;
23
+ cacheWrite: number;
24
+ };
25
+ contextWindow: number;
26
+ maxTokens: number;
27
+ /** Models that don't support reasoning.effort get a thinkingLevelMap. */
28
+ thinkingLevelMap?: Record<string, string | null>;
29
+ }
30
+
31
+ // ─── Hardcoded fallback catalog ───────────────────────────────────────────────
32
+ //
33
+ // These are the models observed via the Grok Build /v1/models endpoint and
34
+ // the actual traffic captured through cli-chat-proxy.grok.com.
35
+
36
+ const FALLBACK_MODELS: GrokBuildModelConfig[] = [
37
+ {
38
+ id: 'grok-composer-2.5-fast',
39
+ name: 'Composer 2.5 Fast (Grok Build)',
40
+ reasoning: false,
41
+ input: ['text', 'image'],
42
+ cost: COST_COMPOSER_FAST,
43
+ contextWindow: 200_000,
44
+ maxTokens: 30_000,
45
+ thinkingLevelMap: {
46
+ off: 'none',
47
+ minimal: null,
48
+ low: null,
49
+ medium: null,
50
+ high: null,
51
+ xhigh: null,
52
+ },
53
+ },
54
+ {
55
+ id: 'grok-build',
56
+ name: 'Grok Build',
57
+ reasoning: true,
58
+ input: ['text', 'image'],
59
+ cost: COST_BUILD,
60
+ contextWindow: 512_000,
61
+ maxTokens: 30_000,
62
+ },
63
+ {
64
+ id: 'grok-4.3',
65
+ name: 'Grok 4.3',
66
+ reasoning: true,
67
+ input: ['text', 'image'],
68
+ cost: COST_43,
69
+ contextWindow: 1_000_000,
70
+ maxTokens: 30_000,
71
+ },
72
+ {
73
+ id: 'grok-4.20-0309-reasoning',
74
+ name: 'Grok 4.20 Reasoning',
75
+ reasoning: true,
76
+ input: ['text', 'image'],
77
+ cost: COST_420,
78
+ contextWindow: 2_000_000,
79
+ maxTokens: 30_000,
80
+ },
81
+ {
82
+ id: 'grok-4.20-0309-non-reasoning',
83
+ name: 'Grok 4.20 Non-Reasoning',
84
+ reasoning: false,
85
+ input: ['text', 'image'],
86
+ cost: COST_420,
87
+ contextWindow: 2_000_000,
88
+ maxTokens: 30_000,
89
+ thinkingLevelMap: {
90
+ off: 'none',
91
+ minimal: null,
92
+ low: null,
93
+ medium: null,
94
+ high: null,
95
+ xhigh: null,
96
+ },
97
+ },
98
+ {
99
+ id: 'grok-4.20-multi-agent-0309',
100
+ name: 'Grok 4.20 Multi-Agent',
101
+ reasoning: true,
102
+ input: ['text', 'image'],
103
+ cost: COST_420,
104
+ contextWindow: 2_000_000,
105
+ maxTokens: 30_000,
106
+ },
107
+ ];
108
+
109
+ const EFFORT_CAPABLE_PREFIXES = ['grok-3-mini', 'grok-4.20-multi-agent', 'grok-4.3'];
110
+
111
+ export function supportsReasoningEffort(modelId: string): boolean {
112
+ const parts = modelId.split('/');
113
+ const name = parts.at(-1) ?? modelId;
114
+ const model = resolveModels().find((entry) => entry.id.toLowerCase() === name.toLowerCase());
115
+ if (!EFFORT_CAPABLE_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) {
116
+ return false;
117
+ }
118
+ if (!model?.reasoning) return false;
119
+ if (!model.thinkingLevelMap) return true;
120
+ return Object.values(model.thinkingLevelMap).some((level) => level !== null && level !== 'none');
121
+ }
122
+
123
+ // ─── GROK_BUILD_MODELS env override ───────────────────────────────────────────
124
+
125
+ /**
126
+ * Resolve the active model list. If `GROK_BUILD_MODELS` is set,
127
+ * it filters/reorders the fallback list; unknown IDs get sensible defaults.
128
+ */
129
+ export function resolveModels(): GrokBuildModelConfig[] {
130
+ const env = (process.env.GROK_BUILD_MODELS || '')
131
+ .split(',')
132
+ .map((s) => s.trim())
133
+ .filter(Boolean);
134
+ if (env.length === 0) return FALLBACK_MODELS;
135
+
136
+ const byId = new Map(FALLBACK_MODELS.map((m) => [m.id, m]));
137
+ return env.map(
138
+ (id) =>
139
+ byId.get(id) ?? {
140
+ id,
141
+ name: id,
142
+ reasoning: true,
143
+ input: ['text'] as ('text' | 'image')[],
144
+ cost: COST_BUILD,
145
+ contextWindow: 1_000_000,
146
+ maxTokens: 30_000,
147
+ },
148
+ );
149
+ }