dsh-plugin-subscription-oauth 0.0.0-stage → 0.2.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +413 -2
  3. package/cordis.patch.yml +13 -0
  4. package/lib/auth/device-flow.d.ts +112 -0
  5. package/lib/auth/device-flow.js +319 -0
  6. package/lib/auth/device-id.d.ts +31 -0
  7. package/lib/auth/device-id.js +58 -0
  8. package/lib/auth/flow.d.ts +33 -0
  9. package/lib/auth/flow.js +12 -0
  10. package/lib/auth/glm-flow.d.ts +99 -0
  11. package/lib/auth/glm-flow.js +194 -0
  12. package/lib/auth/headers.d.ts +61 -0
  13. package/lib/auth/headers.js +106 -0
  14. package/lib/auth/pkce-flow.d.ts +78 -0
  15. package/lib/auth/pkce-flow.js +205 -0
  16. package/lib/auth/provider-controller.d.ts +241 -0
  17. package/lib/auth/provider-controller.js +502 -0
  18. package/lib/auth/registry.d.ts +91 -0
  19. package/lib/auth/registry.js +149 -0
  20. package/lib/auth/rpc.d.ts +31 -0
  21. package/lib/auth/rpc.js +200 -0
  22. package/lib/auth/store.d.ts +99 -0
  23. package/lib/auth/store.js +213 -0
  24. package/lib/auth/tokens.d.ts +76 -0
  25. package/lib/auth/tokens.js +103 -0
  26. package/lib/client/SubscriptionsSection.d.ts +34 -0
  27. package/lib/client/SubscriptionsSection.js +742 -0
  28. package/lib/client/index.d.ts +36 -0
  29. package/lib/client/index.js +47 -0
  30. package/lib/client/locales.d.ts +187 -0
  31. package/lib/client/locales.js +185 -0
  32. package/lib/client/styles.d.ts +18 -0
  33. package/lib/client/styles.js +257 -0
  34. package/lib/client/subscriptions-rpc.d.ts +24 -0
  35. package/lib/client/subscriptions-rpc.js +44 -0
  36. package/lib/client.js +2078 -0
  37. package/lib/client.js.map +1 -0
  38. package/lib/constants.d.ts +79 -0
  39. package/lib/constants.js +91 -0
  40. package/lib/errors.d.ts +28 -0
  41. package/lib/errors.js +34 -0
  42. package/lib/http.d.ts +24 -0
  43. package/lib/http.js +98 -0
  44. package/lib/index.d.ts +57 -0
  45. package/lib/index.js +185 -0
  46. package/lib/protocol.d.ts +316 -0
  47. package/lib/protocol.js +29 -0
  48. package/lib/providers/antigravity-catalog.d.ts +53 -0
  49. package/lib/providers/antigravity-catalog.js +269 -0
  50. package/lib/providers/antigravity.d.ts +331 -0
  51. package/lib/providers/antigravity.js +821 -0
  52. package/lib/providers/auto.d.ts +151 -0
  53. package/lib/providers/auto.js +351 -0
  54. package/lib/providers/catalog.d.ts +69 -0
  55. package/lib/providers/catalog.js +78 -0
  56. package/lib/providers/common.d.ts +51 -0
  57. package/lib/providers/common.js +169 -0
  58. package/lib/providers/glm-provider.d.ts +41 -0
  59. package/lib/providers/glm-provider.js +165 -0
  60. package/lib/providers/glm.d.ts +159 -0
  61. package/lib/providers/glm.js +445 -0
  62. package/lib/providers/grok.d.ts +224 -0
  63. package/lib/providers/grok.js +700 -0
  64. package/lib/providers/kimi-provider.d.ts +45 -0
  65. package/lib/providers/kimi-provider.js +148 -0
  66. package/lib/providers/kimi.d.ts +151 -0
  67. package/lib/providers/kimi.js +400 -0
  68. package/lib/providers/responses-stream.d.ts +58 -0
  69. package/lib/providers/responses-stream.js +115 -0
  70. package/lib/providers/usage.d.ts +65 -0
  71. package/lib/providers/usage.js +309 -0
  72. package/lib/settings.d.ts +122 -0
  73. package/lib/settings.js +333 -0
  74. package/lib/translate/antigravity-schema.d.ts +24 -0
  75. package/lib/translate/antigravity-schema.js +174 -0
  76. package/lib/translate/antigravity.d.ts +184 -0
  77. package/lib/translate/antigravity.js +419 -0
  78. package/lib/translate/responses.d.ts +156 -0
  79. package/lib/translate/responses.js +395 -0
  80. package/lib/translate/sse.d.ts +27 -0
  81. package/lib/translate/sse.js +82 -0
  82. package/package.json +126 -4
@@ -0,0 +1,700 @@
1
+ /**
2
+ * The `grok` provider route: xAI / X Premium (SuperGrok) subscription access.
3
+ *
4
+ * Login is an OIDC authorization-code flow with PKCE against `auth.x.ai`,
5
+ * discovered from the issuer's well-known document and completed on a loopback
6
+ * callback the CLI's registered client allows. Requests then go over xAI's
7
+ * Responses endpoint, which the shared Responses translator already speaks.
8
+ *
9
+ * @module dsh-plugin-subscription-oauth/providers/grok
10
+ */
11
+ import { errorChain, LlmAdapter, LlmError, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
12
+ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
13
+ import { attributedHeaders } from '../auth/headers.js';
14
+ import { LoopbackFlow } from '../auth/pkce-flow.js';
15
+ import { TokenStore, finiteInstant, nonEmpty } from '../auth/store.js';
16
+ import { TokenManager } from '../auth/tokens.js';
17
+ import { ProviderController } from '../auth/provider-controller.js';
18
+ import { proxiedFetch } from '../http.js';
19
+ import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
20
+ import { isMissingOrInvalidCredential } from './common.js';
21
+ import { streamProviderResponses } from './responses-stream.js';
22
+ import { readGrokUsage } from './usage.js';
23
+ import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
24
+ /** Route key this module registers. */
25
+ export const GROK_PROVIDER_ID = 'grok';
26
+ /** Display name for selectors and diagnostics. */
27
+ export const GROK_PROVIDER_NAME = 'Grok (X Premium)';
28
+ /** Where a user manages the underlying subscription. */
29
+ export const GROK_CODE_URL = 'https://x.ai/grok';
30
+ /** OIDC issuer whose well-known document names the endpoints. */
31
+ export const GROK_ISSUER = 'https://auth.x.ai';
32
+ /** Discovery document URL. */
33
+ export const GROK_DISCOVERY_URL = `${GROK_ISSUER}/.well-known/openid-configuration`;
34
+ /** The Grok CLI's registered client id. */
35
+ export const GROK_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
36
+ /** Scopes the CLI requests; `grok-cli:access` is what admits subscription traffic. */
37
+ export const GROK_SCOPE = 'openid profile email offline_access grok-cli:access api:access';
38
+ /** The one loopback port the registered client redirects to. */
39
+ export const GROK_CALLBACK_PORT = 56121;
40
+ /** Path the client redirects to. */
41
+ export const GROK_CALLBACK_PATH = '/callback';
42
+ /** API base; `<base>/responses` streams and `<base>/models` lists. */
43
+ export const GROK_API_BASE = 'https://api.x.ai/v1';
44
+ /** Optional CLI catalog that discloses per-model context and reasoning levels. */
45
+ export const GROK_CLI_MODELS_URL = 'https://cli-chat-proxy.grok.com/v1/models';
46
+ /** Context window assumed when neither the CLI catalog nor a seed states one. */
47
+ export const GROK_CONTEXT_WINDOW = 256_000;
48
+ /** Default per-request output cap. */
49
+ export const GROK_DEFAULT_MAX_TOKENS = 32_000;
50
+ /** Refresh when the access token has less than this much life left. */
51
+ export const GROK_PREEMPT_MS = 2 * 60 * 1000;
52
+ /** Hosts an authorization server endpoint may live on. */
53
+ const TRUSTED_AUTH_HOSTS = new Set(['auth.x.ai', 'accounts.x.ai']);
54
+ /** Display metadata for the reasoning ladder xAI documents. */
55
+ const EFFORT_LABELS = {
56
+ none: { name: 'Off', description: 'Answer without spending thinking tokens' },
57
+ low: { name: 'Low', description: 'Short chain of thought' },
58
+ medium: { name: 'Medium', description: 'Balanced thinking budget' },
59
+ high: { name: 'High', description: 'Deep chain of thought' },
60
+ };
61
+ /**
62
+ * Curated catalog entries.
63
+ *
64
+ * Seeds only what the provider's own documentation names; everything else the
65
+ * account is entitled to arrives through live discovery, so a new model does not
66
+ * need a plugin release.
67
+ */
68
+ export const GROK_MODELS = [
69
+ {
70
+ id: 'grok-4.6',
71
+ name: 'Grok 4.6',
72
+ description: 'Current flagship, reasoning levels from the live catalog',
73
+ contextWindow: GROK_CONTEXT_WINDOW,
74
+ inputModalities: ['text', 'image'],
75
+ },
76
+ {
77
+ id: 'grok-4.5',
78
+ name: 'Grok 4.5',
79
+ description: 'Previous flagship',
80
+ contextWindow: GROK_CONTEXT_WINDOW,
81
+ inputModalities: ['text', 'image'],
82
+ },
83
+ ];
84
+ /**
85
+ * Coerce one decoded credential entry into a Grok session.
86
+ * @param value - decoded JSON value for one account.
87
+ * @returns the session, or undefined when required fields are missing.
88
+ */
89
+ export function parseGrokSession(value) {
90
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
91
+ return undefined;
92
+ const record = value;
93
+ const accessToken = nonEmpty(record.accessToken);
94
+ const tokenEndpoint = nonEmpty(record.tokenEndpoint);
95
+ const expiresAt = finiteInstant(record.expiresAt);
96
+ if (accessToken === undefined || tokenEndpoint === undefined || expiresAt === undefined)
97
+ return undefined;
98
+ const refreshToken = nonEmpty(record.refreshToken);
99
+ const issuer = nonEmpty(record.issuer);
100
+ const scopes = nonEmpty(record.scopes);
101
+ const accountId = nonEmpty(record.accountId);
102
+ const email = nonEmpty(record.email);
103
+ return {
104
+ accessToken,
105
+ expiresAt,
106
+ tokenEndpoint,
107
+ issuer: issuer ?? GROK_ISSUER,
108
+ ...refreshToken === undefined ? {} : { refreshToken },
109
+ ...scopes === undefined ? {} : { scopes },
110
+ ...accountId === undefined ? {} : { accountId },
111
+ ...email === undefined ? {} : { email },
112
+ };
113
+ }
114
+ /** One `reasoning.efforts` entry. */
115
+ function effortInfo(effort) {
116
+ const label = EFFORT_LABELS[effort] ?? { name: effort, description: '' };
117
+ return { id: ReasoningEffortId(effort), name: label.name, description: label.description };
118
+ }
119
+ /** The reasoning block for a catalog entry, or undefined when it supports none. */
120
+ function reasoningInfo(entry) {
121
+ const efforts = entry.reasoningEfforts;
122
+ if (efforts === undefined || efforts.length === 0)
123
+ return undefined;
124
+ return {
125
+ efforts: efforts.map(effortInfo),
126
+ ...entry.defaultEffort === undefined ? {} : { defaultEffort: ReasoningEffortId(entry.defaultEffort) },
127
+ };
128
+ }
129
+ /** Reject an endpoint that is not an xAI HTTPS URL. */
130
+ function assertTrustedEndpoint(raw, field) {
131
+ let url;
132
+ try {
133
+ url = new URL(raw);
134
+ }
135
+ catch {
136
+ throw new Error(`xAI discovery returned an unparseable ${field}`);
137
+ }
138
+ if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.port !== ''
139
+ || !TRUSTED_AUTH_HOSTS.has(url.hostname.toLowerCase())) {
140
+ throw new Error(`xAI discovery returned an untrusted ${field} (${url.hostname})`);
141
+ }
142
+ return url.toString();
143
+ }
144
+ /** Read one JWT payload, or undefined for an opaque token. */
145
+ function decodeJwt(token) {
146
+ if (token === undefined)
147
+ return undefined;
148
+ const parts = token.split('.');
149
+ if (parts.length < 2)
150
+ return undefined;
151
+ try {
152
+ const parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
153
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
159
+ /** Account identity from the id token, falling back to the access token. */
160
+ export function identityFromGrokTokens(accessToken, idToken) {
161
+ const payload = decodeJwt(idToken) ?? decodeJwt(accessToken);
162
+ const accountId = nonEmpty(payload?.sub);
163
+ const email = nonEmpty(payload?.email)?.toLowerCase();
164
+ return {
165
+ ...accountId === undefined ? {} : { accountId },
166
+ ...email === undefined ? {} : { email },
167
+ };
168
+ }
169
+ /** Turn a token-endpoint payload into tokens with an absolute expiry. */
170
+ export function parseGrokTokenPayload(payload, previousRefreshToken) {
171
+ const record = (payload ?? {});
172
+ const accessToken = nonEmpty(record.access_token);
173
+ const lifetime = record.expires_in;
174
+ const lifetimeOk = typeof lifetime === 'number' && Number.isFinite(lifetime) && lifetime >= 0;
175
+ if (accessToken === undefined || !lifetimeOk) {
176
+ throw new Error('xAI token response missing required fields');
177
+ }
178
+ const expiresAt = Date.now() + lifetime * 1000;
179
+ if (!Number.isFinite(expiresAt))
180
+ throw new Error('xAI token response missing required fields');
181
+ const refreshToken = nonEmpty(record.refresh_token) ?? previousRefreshToken;
182
+ const idToken = nonEmpty(record.id_token);
183
+ return {
184
+ accessToken,
185
+ expiresAt,
186
+ ...refreshToken === undefined ? {} : { refreshToken },
187
+ ...idToken === undefined ? {} : { idToken },
188
+ };
189
+ }
190
+ /** OIDC discovery, cached for the process. */
191
+ export function createGrokDiscovery(fetchFn, onWarn) {
192
+ let cached;
193
+ return async function discover() {
194
+ if (cached !== undefined)
195
+ return cached;
196
+ const response = await fetchFn(GROK_DISCOVERY_URL, { headers: { accept: 'application/json' } });
197
+ if (!response.ok) {
198
+ throw new Error(`xAI OIDC discovery failed (HTTP ${response.status})`);
199
+ }
200
+ const payload = await response.json();
201
+ if (typeof payload.authorization_endpoint !== 'string' || typeof payload.token_endpoint !== 'string') {
202
+ throw new Error('xAI OIDC discovery response is missing authorization/token endpoints');
203
+ }
204
+ const endpoints = {
205
+ authorizationEndpoint: assertTrustedEndpoint(payload.authorization_endpoint, 'authorization_endpoint'),
206
+ tokenEndpoint: assertTrustedEndpoint(payload.token_endpoint, 'token_endpoint'),
207
+ };
208
+ onWarn?.(`xAI endpoints discovered (${new URL(endpoints.tokenEndpoint).host})`);
209
+ cached = endpoints;
210
+ return endpoints;
211
+ };
212
+ }
213
+ /** Exchange an authorization code for tokens. */
214
+ export async function exchangeGrokCode(input) {
215
+ const response = await input.fetchFn(input.tokenEndpoint, {
216
+ method: 'POST',
217
+ headers: { ...attributedHeaders(), 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
218
+ body: new URLSearchParams({
219
+ grant_type: 'authorization_code',
220
+ client_id: GROK_CLIENT_ID,
221
+ code: input.code,
222
+ redirect_uri: input.redirectUri,
223
+ code_verifier: input.pkce.verifier,
224
+ code_challenge: input.pkce.challenge,
225
+ code_challenge_method: 'S256',
226
+ }).toString(),
227
+ signal: input.signal,
228
+ });
229
+ const payload = await response.json().catch(() => undefined);
230
+ if (!response.ok) {
231
+ const detail = (payload ?? {});
232
+ const described = nonEmpty(detail.error_description) ?? nonEmpty(detail.error);
233
+ throw new Error(`xAI token exchange failed (HTTP ${response.status})${described === undefined ? '' : `: ${described}`}`);
234
+ }
235
+ return parseGrokTokenPayload(payload);
236
+ }
237
+ /** Redeem a refresh token. */
238
+ export async function refreshGrokToken(input) {
239
+ const response = await input.fetchFn(input.tokenEndpoint, {
240
+ method: 'POST',
241
+ headers: { ...attributedHeaders(), 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
242
+ body: new URLSearchParams({
243
+ grant_type: 'refresh_token',
244
+ client_id: GROK_CLIENT_ID,
245
+ refresh_token: input.refreshToken,
246
+ }).toString(),
247
+ });
248
+ const payload = await response.json().catch(() => undefined);
249
+ if (!response.ok) {
250
+ const detail = (payload ?? {});
251
+ const described = nonEmpty(detail.error_description) ?? nonEmpty(detail.error);
252
+ throw new ProviderError(`xAI token refresh failed (HTTP ${response.status})${described === undefined ? '' : `: ${described}`}`, REAUTH_REQUIRED);
253
+ }
254
+ return parseGrokTokenPayload(payload, input.refreshToken);
255
+ }
256
+ /** Model ids that are clearly not chat models. */
257
+ const NON_CHAT_ID = /(?:^|[-_/])(?:image|video|imagine|embed|embedding|whisper|tts|vision-only)/i;
258
+ /** Grok wire adapter: one instance serves the `grok` provider route. */
259
+ export class GrokAdapter extends LlmAdapter {
260
+ options;
261
+ /** Live model ids seen this session. */
262
+ discovered;
263
+ /** Capability metadata from the CLI catalog, keyed by model id. */
264
+ metadata = new Map();
265
+ /** Ids the user added by hand, cached so the sync catalog can name them. */
266
+ userAddedIds = [];
267
+ constructor(options) {
268
+ super();
269
+ this.options = options;
270
+ }
271
+ providerInfo(provider) {
272
+ return { id: provider, name: GROK_PROVIDER_NAME };
273
+ }
274
+ providerRetryPolicy() {
275
+ // xAI reports its own pacing through 429 `retry-after`.
276
+ return undefined;
277
+ }
278
+ async listModels(_provider) {
279
+ // Nothing to offer without a signed-in account: a model that cannot be called
280
+ // must not appear in the picker, and Auto must not try it.
281
+ if (await this.options.tokens.session() === undefined)
282
+ return [];
283
+ await this.syncUserModels();
284
+ const hidden = await this.hidden();
285
+ return resolveCatalog(this.catalog(), [], hidden).entries.map((entry) => ({
286
+ provider: GROK_PROVIDER_ID,
287
+ id: entry.id,
288
+ name: entry.name,
289
+ ...entry.description === undefined ? {} : { description: entry.description },
290
+ ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
291
+ }));
292
+ }
293
+ async hidden() {
294
+ if (this.options.hiddenModels === undefined)
295
+ return new Set();
296
+ try {
297
+ return await this.options.hiddenModels();
298
+ }
299
+ catch (error) {
300
+ this.options.onWarn?.(`model preferences unavailable (${errorChain(error)})`);
301
+ return new Set();
302
+ }
303
+ }
304
+ async resolveModel(_provider, model) {
305
+ await this.syncUserModels();
306
+ const entry = findEntry(this.catalog(), model);
307
+ if (entry === undefined) {
308
+ const template = findEntry(GROK_MODELS, 'grok-4.6');
309
+ const reasoning = template === undefined ? undefined : reasoningInfo(template);
310
+ return {
311
+ provider: GROK_PROVIDER_ID,
312
+ id: model,
313
+ name: model,
314
+ context: { contextWindow: GROK_CONTEXT_WINDOW },
315
+ defaultMaxTokens: GROK_DEFAULT_MAX_TOKENS,
316
+ ...reasoning === undefined ? {} : { reasoning },
317
+ };
318
+ }
319
+ const reasoning = reasoningInfo(entry);
320
+ return {
321
+ provider: GROK_PROVIDER_ID,
322
+ id: entry.id,
323
+ name: entry.name,
324
+ ...entry.description === undefined ? {} : { description: entry.description },
325
+ ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
326
+ context: { contextWindow: entry.contextWindow },
327
+ defaultMaxTokens: GROK_DEFAULT_MAX_TOKENS,
328
+ ...reasoning === undefined ? {} : { reasoning },
329
+ };
330
+ }
331
+ stream(options) {
332
+ return streamProviderResponses(options, {
333
+ label: 'xAI API',
334
+ tokens: this.options.tokens,
335
+ request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
336
+ streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
337
+ ...this.options.onCredentialRejected === undefined
338
+ ? {}
339
+ : { onCredentialRejected: this.options.onCredentialRejected },
340
+ onFallback: (from, to, reason) => {
341
+ this.options.onWarn?.(`grok: model ${from} unavailable (${reason}); falling back to ${to}`);
342
+ },
343
+ describeFailure: (error) => (error instanceof ProviderError && error.code === REAUTH_REQUIRED
344
+ ? new LlmError(`Grok (X Premium) login expired; sign in again from Settings → Subscriptions (${GROK_CODE_URL})`, 'INVALID_CREDENTIAL', { status: 401 })
345
+ : error),
346
+ });
347
+ }
348
+ /**
349
+ * The catalog the route serves: its own entries, then the ids the user added
350
+ * by hand. The two are built separately so the hand-added check can look at
351
+ * the shipped list without recursing.
352
+ */
353
+ catalog() {
354
+ return [...this.shippedCatalog(), ...this.addedEntries()];
355
+ }
356
+ /**
357
+ * The catalog to present: curated seeds with their shipped metadata, then any
358
+ * live id this build does not know, enriched from the CLI catalog when it
359
+ * disclosed that model's context window or reasoning ladder.
360
+ */
361
+ shippedCatalog() {
362
+ const extra = [];
363
+ const seen = new Set();
364
+ for (const id of this.discovered ?? []) {
365
+ if (findEntry(GROK_MODELS, id) !== undefined || seen.has(id))
366
+ continue;
367
+ seen.add(id);
368
+ const meta = this.metadata.get(id);
369
+ extra.push({
370
+ id,
371
+ name: id,
372
+ description: 'Reported by the xAI model list',
373
+ contextWindow: meta?.contextWindow ?? GROK_CONTEXT_WINDOW,
374
+ inputModalities: ['text', 'image'],
375
+ // A ladder is advertised only when the provider's own catalog gave one.
376
+ ...meta?.reasoningEfforts === undefined ? {} : { reasoningEfforts: meta.reasoningEfforts },
377
+ ...meta?.defaultEffort === undefined ? {} : { defaultEffort: meta.defaultEffort },
378
+ });
379
+ }
380
+ // The seeds also pick up live metadata (context window, reasoning ladder).
381
+ const seeded = GROK_MODELS.map((entry) => {
382
+ const meta = this.metadata.get(entry.id);
383
+ if (meta === undefined)
384
+ return entry;
385
+ return {
386
+ ...entry,
387
+ contextWindow: meta.contextWindow ?? entry.contextWindow,
388
+ ...meta.reasoningEfforts === undefined ? {} : { reasoningEfforts: meta.reasoningEfforts },
389
+ ...meta.defaultEffort === undefined ? {} : { defaultEffort: meta.defaultEffort },
390
+ };
391
+ });
392
+ return [...seeded, ...extra];
393
+ }
394
+ /** Re-read the ids the user added by hand. */
395
+ async syncUserModels() {
396
+ if (this.options.userModels === undefined)
397
+ return;
398
+ try {
399
+ this.userAddedIds = await this.options.userModels();
400
+ }
401
+ catch (error) {
402
+ this.options.onWarn?.(`user-added models unavailable (${errorChain(error)})`);
403
+ this.userAddedIds = [];
404
+ }
405
+ }
406
+ /**
407
+ * The entries for ids the user added by hand.
408
+ *
409
+ * Published without shipped metadata: this build has never seen the model, so
410
+ * the endpoint's own defaults are the honest description.
411
+ */
412
+ addedEntries() {
413
+ if (this.userAddedIds.length === 0)
414
+ return [];
415
+ const known = new Set(this.shippedCatalog().map((entry) => entry.id));
416
+ return this.userAddedIds
417
+ .filter((id) => !known.has(id))
418
+ .map((id) => fallbackEntry(id, 'Added by hand in Settings', GROK_CONTEXT_WINDOW));
419
+ }
420
+ /** Record the live catalog for the picker. */
421
+ discover(ids, metadata) {
422
+ this.discovered = ids;
423
+ if (metadata === undefined)
424
+ return;
425
+ for (const [id, meta] of metadata)
426
+ this.metadata.set(id, meta);
427
+ }
428
+ /** The curated catalog, for the Settings page's model list. */
429
+ catalogEntries() {
430
+ return this.catalog();
431
+ }
432
+ /** What this route itself serves, without the ids added by hand. */
433
+ ownCatalogEntries() {
434
+ return this.shippedCatalog();
435
+ }
436
+ /** Build and send one request for one model. */
437
+ async request(options, model, accessToken, signal) {
438
+ const resolved = await toResponsesInput(options.messages, options.system, this.imageResolver(signal));
439
+ const body = {
440
+ model,
441
+ ...resolved.instructions === undefined ? {} : { instructions: resolved.instructions },
442
+ input: resolved.input,
443
+ ...options.tools === undefined || options.tools.length === 0
444
+ ? {}
445
+ : { tools: toResponsesTools(options.tools), tool_choice: 'auto', parallel_tool_calls: true },
446
+ ...options.maxTokens === undefined ? {} : { max_output_tokens: options.maxTokens },
447
+ ...options.reasoningEffort === undefined
448
+ ? {}
449
+ : { reasoning: { effort: String(options.reasoningEffort) } },
450
+ // Cache-affinity hint: xAI routes a repeated request back to the shard
451
+ // holding its prompt cache only when the same key returns.
452
+ ...options.sessionId === undefined ? {} : { prompt_cache_key: String(options.sessionId) },
453
+ store: false,
454
+ stream: true,
455
+ };
456
+ const fetchFn = this.options.fetchFn ?? proxiedFetch;
457
+ return fetchFn(`${this.options.apiBase}/responses`, {
458
+ method: 'POST',
459
+ headers: attributedHeaders({
460
+ 'authorization': `Bearer ${accessToken}`,
461
+ 'accept': 'text/event-stream',
462
+ 'content-type': 'application/json',
463
+ }),
464
+ body: JSON.stringify(body),
465
+ signal,
466
+ });
467
+ }
468
+ /** Resolve image blocks through the attachment service, when it is present. */
469
+ imageResolver(signal) {
470
+ const store = this.options.resolveAttachments?.();
471
+ if (store === undefined || store === null)
472
+ return undefined;
473
+ return async (ref) => {
474
+ try {
475
+ const stored = await store.readImage(ref, signal);
476
+ return { mediaType: ref.mediaType, base64: Buffer.from(stored.data).toString('base64') };
477
+ }
478
+ catch (error) {
479
+ this.options.onWarn?.(`image attachment unavailable (${errorChain(error)})`);
480
+ return undefined;
481
+ }
482
+ };
483
+ }
484
+ /**
485
+ * Read the live model list, and the CLI catalog's capability metadata.
486
+ * @param accessToken - bearer for the account's models.
487
+ * @returns the model ids, or undefined when discovery is off or unavailable.
488
+ */
489
+ async fetchCatalog(accessToken, signal) {
490
+ if (!this.options.discovery)
491
+ return undefined;
492
+ const fetchFn = this.options.fetchFn ?? proxiedFetch;
493
+ try {
494
+ const response = await fetchFn(`${this.options.apiBase}/models`, {
495
+ headers: attributedHeaders({ 'authorization': `Bearer ${accessToken}` }),
496
+ ...signal === undefined ? {} : { signal },
497
+ });
498
+ if (!response.ok) {
499
+ // A refused credential is reported by the card's account list; only a
500
+ // genuine discovery failure is worth a warning.
501
+ if (response.status !== 401 && response.status !== 403) {
502
+ this.options.onWarn?.(`xAI model discovery failed (HTTP ${response.status}); using the built-in catalog`);
503
+ }
504
+ return undefined;
505
+ }
506
+ const payload = await response.json();
507
+ const ids = (payload.data ?? [])
508
+ .map((entry) => entry.id)
509
+ .filter((id) => typeof id === 'string' && id.length > 0 && !NON_CHAT_ID.test(id));
510
+ if (ids.length === 0) {
511
+ this.options.onWarn?.('xAI model discovery returned an empty catalog; using the built-in catalog');
512
+ return undefined;
513
+ }
514
+ const metadata = await this.fetchCliMetadata(fetchFn, signal);
515
+ for (const [id, meta] of metadata)
516
+ this.metadata.set(id, meta);
517
+ return ids;
518
+ }
519
+ catch (error) {
520
+ if (signal?.aborted === true)
521
+ return undefined;
522
+ this.options.onWarn?.(`xAI model discovery failed; using the built-in catalog (${errorChain(error)})`);
523
+ return undefined;
524
+ }
525
+ }
526
+ /** Read per-model capabilities from the CLI catalog; failures are silent. */
527
+ async fetchCliMetadata(fetchFn, signal) {
528
+ const result = new Map();
529
+ try {
530
+ const response = await fetchFn(GROK_CLI_MODELS_URL, {
531
+ headers: attributedHeaders({ 'authorization': 'Bearer anonymous' }),
532
+ ...signal === undefined ? {} : { signal },
533
+ });
534
+ if (!response.ok)
535
+ return result;
536
+ const payload = await response.json();
537
+ for (const entry of payload.data ?? []) {
538
+ const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : undefined;
539
+ if (id === undefined)
540
+ continue;
541
+ const contextWindow = typeof entry.context_window === 'number' && Number.isFinite(entry.context_window)
542
+ ? entry.context_window
543
+ : undefined;
544
+ const supportsEffort = entry.supports_reasoning_effort === true;
545
+ const efforts = supportsEffort
546
+ ? (entry.reasoning_efforts ?? [])
547
+ .map((effort) => effort.value)
548
+ .filter((value) => typeof value === 'string' && value.length > 0)
549
+ : [];
550
+ const defaultEffort = supportsEffort && typeof entry.reasoning_effort === 'string'
551
+ && efforts.includes(entry.reasoning_effort)
552
+ ? entry.reasoning_effort
553
+ : undefined;
554
+ result.set(id, {
555
+ ...contextWindow === undefined ? {} : { contextWindow },
556
+ ...efforts.length === 0 ? {} : { reasoningEfforts: efforts },
557
+ ...defaultEffort === undefined ? {} : { defaultEffort },
558
+ });
559
+ }
560
+ }
561
+ catch {
562
+ // Capability metadata is a bonus; the id list is what matters.
563
+ }
564
+ return result;
565
+ }
566
+ }
567
+ /**
568
+ * Build the `grok` route: its adapter and its login/lifecycle controller.
569
+ *
570
+ * @param options - resolved paths and shared services.
571
+ * @returns the route, ready to register and to serve over RPC.
572
+ */
573
+ export function createGrokProvider(options) {
574
+ const fetchFn = options.fetchFn ?? proxiedFetch;
575
+ const apiBase = options.apiBase ?? GROK_API_BASE;
576
+ const store = new TokenStore(options.credentialFile, GROK_PROVIDER_ID, parseGrokSession);
577
+ const discoverEndpoints = createGrokDiscovery(fetchFn, options.onWarn);
578
+ /** Endpoints resolved by `prepare`, read synchronously while building URLs. */
579
+ let endpointsSnapshot;
580
+ const flow = new LoopbackFlow(GROK_PROVIDER_ID, {
581
+ ports: [GROK_CALLBACK_PORT],
582
+ callbackPath: GROK_CALLBACK_PATH,
583
+ prepare: async () => { endpointsSnapshot = await discoverEndpoints(); },
584
+ buildAuthorizeUrl: ({ redirectUri, state, pkce }) => {
585
+ // `prepare` has already resolved and cached the endpoints.
586
+ const endpoints = endpointsSnapshot;
587
+ if (endpoints === undefined)
588
+ throw new Error('xAI endpoints were not discovered');
589
+ const url = new URL(endpoints.authorizationEndpoint);
590
+ url.searchParams.set('response_type', 'code');
591
+ url.searchParams.set('client_id', GROK_CLIENT_ID);
592
+ url.searchParams.set('redirect_uri', redirectUri);
593
+ url.searchParams.set('scope', GROK_SCOPE);
594
+ url.searchParams.set('state', state);
595
+ url.searchParams.set('code_challenge', pkce.challenge);
596
+ url.searchParams.set('code_challenge_method', 'S256');
597
+ return url.toString();
598
+ },
599
+ exchange: async ({ code, redirectUri, pkce, signal }) => {
600
+ const endpoints = endpointsSnapshot ?? await discoverEndpoints();
601
+ const tokens = await exchangeGrokCode({
602
+ fetchFn,
603
+ tokenEndpoint: endpoints.tokenEndpoint,
604
+ code,
605
+ redirectUri,
606
+ pkce,
607
+ signal,
608
+ });
609
+ return {
610
+ accessToken: tokens.accessToken,
611
+ ...tokens.refreshToken === undefined ? {} : { refreshToken: tokens.refreshToken },
612
+ expiresAt: tokens.expiresAt,
613
+ tokenEndpoint: endpoints.tokenEndpoint,
614
+ issuer: GROK_ISSUER,
615
+ scopes: GROK_SCOPE,
616
+ ...identityFromGrokTokens(tokens.accessToken, tokens.idToken),
617
+ };
618
+ },
619
+ });
620
+ const tokens = new TokenManager({
621
+ store,
622
+ displayName: GROK_PROVIDER_NAME,
623
+ preemptMs: GROK_PREEMPT_MS,
624
+ refresh: async (session) => {
625
+ const refreshToken = session.refreshToken;
626
+ if (refreshToken === undefined) {
627
+ throw new ProviderError(`${GROK_PROVIDER_NAME} account has no refresh token; sign in again`, REAUTH_REQUIRED);
628
+ }
629
+ const refreshed = await refreshGrokToken({
630
+ fetchFn,
631
+ tokenEndpoint: session.tokenEndpoint,
632
+ refreshToken,
633
+ });
634
+ return {
635
+ accessToken: refreshed.accessToken,
636
+ expiresAt: refreshed.expiresAt,
637
+ ...refreshed.refreshToken === undefined ? {} : { refreshToken: refreshed.refreshToken },
638
+ };
639
+ },
640
+ });
641
+ const adapter = new GrokAdapter({
642
+ apiBase,
643
+ discovery: options.discovery,
644
+ streamIdleTimeoutMs: options.streamIdleTimeoutMs,
645
+ tokens,
646
+ ...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
647
+ ...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
648
+ hiddenModels: () => options.preferences.hiddenIds(GROK_PROVIDER_ID),
649
+ userModels: () => options.preferences.extraIds(GROK_PROVIDER_ID),
650
+ modelOrder: () => options.preferences.orderOf(GROK_PROVIDER_ID),
651
+ ...options.discoveryFetchFn === undefined ? {} : { fetchFn: options.discoveryFetchFn },
652
+ });
653
+ const spec = {
654
+ id: GROK_PROVIDER_ID,
655
+ displayName: GROK_PROVIDER_NAME,
656
+ codeUrl: GROK_CODE_URL,
657
+ credentialFile: options.credentialFile,
658
+ discovery: options.discovery,
659
+ defaultModel: 'grok-4.6',
660
+ catalog: () => adapter.catalogEntries(),
661
+ catalogOwn: () => adapter.ownCatalogEntries(),
662
+ fallbackContextWindow: GROK_CONTEXT_WINDOW,
663
+ discover: (accessToken) => adapter.fetchCatalog(accessToken),
664
+ usage: (accessToken) => readGrokUsage({ accessToken, fetchFn: options.discoveryFetchFn ?? fetchFn }),
665
+ details: () => [
666
+ { key: 'oauthHost', value: GROK_ISSUER },
667
+ { key: 'apiBase', value: apiBase },
668
+ { key: 'credentialFile', value: options.credentialFile },
669
+ ],
670
+ applyDiscovery: (ids) => { adapter.discover(ids); },
671
+ };
672
+ const controller = new ProviderController({
673
+ spec,
674
+ store,
675
+ tokens,
676
+ flow,
677
+ preferences: options.preferences,
678
+ ...options.onCatalogChanged === undefined ? {} : { onCatalogChanged: options.onCatalogChanged },
679
+ ...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
680
+ });
681
+ return {
682
+ id: controller.id,
683
+ displayName: controller.displayName,
684
+ credentialFile: controller.credentialFile,
685
+ adapter,
686
+ status: () => controller.status(),
687
+ login: (account) => controller.login(account),
688
+ cancel: () => controller.cancel(),
689
+ logout: (account) => controller.logout(account),
690
+ setDefault: (account) => controller.setDefault(account),
691
+ refresh: (account) => controller.refresh(account),
692
+ discover: (force) => controller.discover(force),
693
+ setModelVisible: (id, visible) => controller.setModelVisible(id, visible),
694
+ setModelOrder: (ids) => controller.setModelOrder(ids),
695
+ addModel: (id) => controller.addModel(id),
696
+ removeModel: (id) => controller.removeModel(id),
697
+ usage: (force) => controller.usage(force),
698
+ reload: () => controller.reload(),
699
+ };
700
+ }