aisubs 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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +629 -0
  3. package/dashboard/public/aisubs-mark.svg +11 -0
  4. package/dist/account-key.d.ts +2 -0
  5. package/dist/account-key.js +11 -0
  6. package/dist/auth.d.ts +76 -0
  7. package/dist/auth.js +477 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +97 -0
  10. package/dist/dashboard/aisubs-mark.svg +11 -0
  11. package/dist/dashboard/assets/index-BMoILzPw.js +64 -0
  12. package/dist/dashboard/assets/index-DHqDdNVe.css +2 -0
  13. package/dist/dashboard/index.html +15 -0
  14. package/dist/dashboard/logos/anthropic.svg +3 -0
  15. package/dist/dashboard/logos/github-copilot.svg +3 -0
  16. package/dist/dashboard/logos/google.svg +3 -0
  17. package/dist/dashboard/logos/openai.svg +3 -0
  18. package/dist/dashboard/logos/opencode-dark.svg +18 -0
  19. package/dist/dashboard/logos/opencode-light.svg +18 -0
  20. package/dist/dashboard/logos/xai.svg +3 -0
  21. package/dist/dashboard.d.ts +18 -0
  22. package/dist/dashboard.js +139 -0
  23. package/dist/http.d.ts +22 -0
  24. package/dist/http.js +263 -0
  25. package/dist/index.d.ts +9 -0
  26. package/dist/index.js +8 -0
  27. package/dist/providers/chatgpt.d.ts +7 -0
  28. package/dist/providers/chatgpt.js +402 -0
  29. package/dist/providers/claude.d.ts +7 -0
  30. package/dist/providers/claude.js +289 -0
  31. package/dist/providers/copilot.d.ts +6 -0
  32. package/dist/providers/copilot.js +466 -0
  33. package/dist/providers/grok.d.ts +7 -0
  34. package/dist/providers/grok.js +215 -0
  35. package/dist/providers/opencode.d.ts +4 -0
  36. package/dist/providers/opencode.js +147 -0
  37. package/dist/store.d.ts +18 -0
  38. package/dist/store.js +121 -0
  39. package/dist/types.d.ts +186 -0
  40. package/dist/types.js +1 -0
  41. package/dist/usage.d.ts +5 -0
  42. package/dist/usage.js +418 -0
  43. package/dist/utils.d.ts +11 -0
  44. package/dist/utils.js +68 -0
  45. package/examples/direct.mjs +38 -0
  46. package/examples/server.mjs +20 -0
  47. package/package.json +122 -0
  48. package/public/aisubs-chatgpt-account.png +0 -0
  49. package/public/aisubs-copilot-account.png +0 -0
  50. package/public/aisubs-dashboard.png +0 -0
  51. package/public/aisubs-grok-account.png +0 -0
@@ -0,0 +1,466 @@
1
+ import { copilotPlanName, parseCopilotUsage } from "../usage.js";
2
+ import { abortableDelay, bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
3
+ const DEFAULT_CLIENT_ID = "Iv1.b507a08c87ecfe98";
4
+ const EXPIRY_SKEW_MS = 5 * 60_000;
5
+ const AUTO_API_VERSION = "2025-10-01";
6
+ const HEADERS = {
7
+ "user-agent": "GitHubCopilotChat/0.35.0",
8
+ "editor-version": "vscode/1.107.0",
9
+ "editor-plugin-version": "copilot-chat/0.35.0",
10
+ "copilot-integration-id": "vscode-chat",
11
+ "x-github-api-version": "2026-06-01",
12
+ };
13
+ class CopilotTokenError extends Error {
14
+ status;
15
+ constructor(status) {
16
+ super(`Copilot token exchange failed (${status})`);
17
+ this.status = status;
18
+ }
19
+ }
20
+ function isAutoModel(id) {
21
+ return id === "auto" || id.endsWith("-free-auto");
22
+ }
23
+ function promptText(value, out) {
24
+ if (typeof value === "string")
25
+ out.push(value);
26
+ else if (Array.isArray(value)) {
27
+ for (const item of value) {
28
+ if (isRecord(item))
29
+ promptText(item.text ?? item.content ?? item.input, out);
30
+ }
31
+ }
32
+ }
33
+ function normalizeResponsesStream(body) {
34
+ const ids = new Map();
35
+ let currentTextId;
36
+ let currentReasoningId;
37
+ const decoder = new TextDecoder();
38
+ let buffer = "";
39
+ const normalize = (frame) => frame.replace(/^(\s*data:\s*)(.+)$/gm, (line, prefix, data) => {
40
+ if (data.trim() === "[DONE]")
41
+ return line;
42
+ try {
43
+ const value = JSON.parse(data);
44
+ if (!isRecord(value))
45
+ return line;
46
+ const outputIndex = numberValue(value.output_index);
47
+ const item = isRecord(value.item) ? value.item : undefined;
48
+ if (value.type === "response.output_item.added" &&
49
+ outputIndex != null &&
50
+ typeof item?.id === "string") {
51
+ ids.set(outputIndex, item.id);
52
+ if (item.type === "message")
53
+ currentTextId = item.id;
54
+ if (item.type === "reasoning")
55
+ currentReasoningId = item.id;
56
+ }
57
+ const type = stringValue(value.type) ?? "";
58
+ const canonicalId = (outputIndex == null ? undefined : ids.get(outputIndex)) ??
59
+ (type.startsWith("response.reasoning_summary_")
60
+ ? currentReasoningId
61
+ : type.startsWith("response.output_text.") ||
62
+ type.startsWith("response.content_part.") ||
63
+ type.startsWith("response.refusal.")
64
+ ? currentTextId
65
+ : undefined);
66
+ if (canonicalId && typeof value.item_id === "string")
67
+ value.item_id = canonicalId;
68
+ if (canonicalId &&
69
+ value.type === "response.output_item.done" &&
70
+ item &&
71
+ typeof item.id === "string") {
72
+ item.id = canonicalId;
73
+ }
74
+ return `${prefix}${JSON.stringify(value)}`;
75
+ }
76
+ catch {
77
+ return line;
78
+ }
79
+ });
80
+ return body
81
+ .pipeThrough(new TransformStream({
82
+ transform(chunk, controller) {
83
+ controller.enqueue(decoder.decode(chunk, { stream: true }));
84
+ },
85
+ flush(controller) {
86
+ const rest = decoder.decode();
87
+ if (rest)
88
+ controller.enqueue(rest);
89
+ },
90
+ }))
91
+ .pipeThrough(new TransformStream({
92
+ transform(chunk, controller) {
93
+ buffer += chunk;
94
+ let match = buffer.match(/\r?\n\r?\n/);
95
+ while (match?.index != null) {
96
+ const end = match.index + match[0].length;
97
+ controller.enqueue(normalize(buffer.slice(0, end)));
98
+ buffer = buffer.slice(end);
99
+ match = buffer.match(/\r?\n\r?\n/);
100
+ }
101
+ },
102
+ flush(controller) {
103
+ if (buffer)
104
+ controller.enqueue(normalize(buffer));
105
+ },
106
+ }))
107
+ .pipeThrough(new TextEncoderStream());
108
+ }
109
+ function normalizeModel(value) {
110
+ if (!isRecord(value))
111
+ return null;
112
+ const id = stringValue(value.id);
113
+ if (!id)
114
+ return null;
115
+ const isAuto = id.endsWith("-free-auto");
116
+ const policy = isRecord(value.policy) ? value.policy : {};
117
+ if (policy.state === "disabled")
118
+ return null;
119
+ const selectable = value.model_picker_enabled === true || isAuto;
120
+ if (policy.state !== "enabled" && !selectable)
121
+ return null;
122
+ const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
123
+ const limits = isRecord(capabilities.limits) ? capabilities.limits : {};
124
+ const supports = isRecord(capabilities.supports) ? capabilities.supports : {};
125
+ if (supports.tool_calls === false)
126
+ return null;
127
+ const modalities = ["text"];
128
+ if (supports.vision === true || isRecord(limits.vision))
129
+ modalities.push("image");
130
+ return {
131
+ id,
132
+ name: isAuto ? "Auto" : stringValue(value.name),
133
+ contextWindow: numberValue(limits.max_context_window_tokens) ?? numberValue(limits.max_prompt_tokens),
134
+ maxOutputTokens: numberValue(limits.max_output_tokens),
135
+ reasoningEfforts: stringArray(supports.reasoning_effort),
136
+ inputModalities: modalities,
137
+ endpoints: stringArray(value.supported_endpoints)?.map((endpoint) => endpoint.replace(/^\//, "")),
138
+ ...(supports.tool_calls === true || isAuto ? { supportsToolCall: true } : {}),
139
+ available: true,
140
+ selectable,
141
+ };
142
+ }
143
+ function normalizeDomain(input) {
144
+ if (typeof input !== "string" || !input.trim())
145
+ return "github.com";
146
+ let hostname;
147
+ try {
148
+ hostname = new URL(input.includes("://") ? input : `https://${input}`).hostname;
149
+ }
150
+ catch {
151
+ throw new Error("Invalid GitHub Enterprise domain");
152
+ }
153
+ if (hostname !== "github.com" && !hostname.endsWith(".ghe.com")) {
154
+ throw new Error("Only github.com and GitHub Enterprise Cloud data-residency hosts (*.ghe.com) are supported");
155
+ }
156
+ return hostname;
157
+ }
158
+ function endpoints(domain) {
159
+ return {
160
+ deviceCode: `https://${domain}/login/device/code`,
161
+ accessToken: `https://${domain}/login/oauth/access_token`,
162
+ copilotToken: `https://api.${domain}/copilot_internal/v2/token`,
163
+ user: `https://api.${domain}/copilot_internal/user`,
164
+ };
165
+ }
166
+ function baseUrlFromToken(token, enterpriseDomain) {
167
+ const proxyHost = token.match(/proxy-ep=([^;]+)/)?.[1];
168
+ if (proxyHost)
169
+ return `https://${proxyHost.replace(/^proxy\./, "api.")}`;
170
+ if (enterpriseDomain && enterpriseDomain !== "github.com") {
171
+ return `https://copilot-api.${enterpriseDomain}`;
172
+ }
173
+ return "https://api.individual.githubcopilot.com";
174
+ }
175
+ export function copilotProvider(options = {}) {
176
+ const clientId = options.clientId ?? DEFAULT_CLIENT_ID;
177
+ const fetcher = options.fetch ?? globalThis.fetch;
178
+ const autoSessions = new Map();
179
+ async function routeAuto(body, credential, signal) {
180
+ const baseUrl = stringValue(credential.metadata?.baseUrl);
181
+ if (!baseUrl)
182
+ throw new Error("Copilot API base URL is missing");
183
+ const key = `${baseUrl}:${credential.account?.id ?? credential.account?.label ?? "default"}`;
184
+ let session = autoSessions.get(key);
185
+ if (!session ||
186
+ (session.expiresAt != null && Date.now() >= session.expiresAt * 1000 - 60_000)) {
187
+ const raw = await responseJson(await fetcher(`${baseUrl}/models/session`, {
188
+ method: "POST",
189
+ headers: {
190
+ ...HEADERS,
191
+ authorization: `Bearer ${credential.accessToken}`,
192
+ "content-type": "application/json",
193
+ "x-github-api-version": AUTO_API_VERSION,
194
+ },
195
+ body: JSON.stringify({ auto_mode: { model_hints: ["auto"] } }),
196
+ signal,
197
+ }), "Copilot Auto session setup");
198
+ const token = stringValue(raw.session_token);
199
+ const selectedModel = stringValue(raw.selected_model);
200
+ if (!token || !selectedModel)
201
+ throw new Error("Copilot Auto session response is incomplete");
202
+ session = {
203
+ token,
204
+ selectedModel,
205
+ availableModels: stringArray(raw.available_models) ?? [],
206
+ previousModel: null,
207
+ turnNumber: 0,
208
+ expiresAt: numberValue(raw.expires_at),
209
+ };
210
+ }
211
+ const prompt = [];
212
+ promptText(body.instructions, prompt);
213
+ promptText(body.input ?? body.messages, prompt);
214
+ const intentResponse = await fetcher(`${baseUrl}/models/session/intent`, {
215
+ method: "POST",
216
+ headers: {
217
+ ...HEADERS,
218
+ authorization: `Bearer ${credential.accessToken}`,
219
+ "content-type": "application/json",
220
+ "copilot-session-token": session.token,
221
+ "x-github-api-version": AUTO_API_VERSION,
222
+ },
223
+ body: JSON.stringify({
224
+ prompt: prompt.join("\n"),
225
+ available_models: session.availableModels,
226
+ turn_number: session.turnNumber + 1,
227
+ previous_model: session.previousModel,
228
+ reference_count: 0,
229
+ prompt_char_count: prompt.join("\n").length,
230
+ }),
231
+ signal,
232
+ });
233
+ let selectedModel = session.selectedModel;
234
+ if (intentResponse.ok) {
235
+ const intent = await responseJson(intentResponse, "Copilot Auto intent");
236
+ selectedModel =
237
+ stringValue(intent.chosen_model) ??
238
+ stringValue(intent.selected_model) ??
239
+ stringValue(intent.model) ??
240
+ selectedModel;
241
+ }
242
+ session = {
243
+ ...session,
244
+ selectedModel,
245
+ previousModel: selectedModel,
246
+ turnNumber: session.turnNumber + 1,
247
+ };
248
+ autoSessions.set(key, session);
249
+ body.model = selectedModel;
250
+ return session;
251
+ }
252
+ async function exchange(githubToken, domain, signal) {
253
+ const response = await fetcher(endpoints(domain).copilotToken, {
254
+ headers: { accept: "application/json", authorization: `Bearer ${githubToken}`, ...HEADERS },
255
+ signal,
256
+ });
257
+ if (!response.ok)
258
+ throw new CopilotTokenError(response.status);
259
+ const raw = await responseJson(response, "Copilot token exchange");
260
+ const accessToken = stringValue(raw.token);
261
+ const expiresAt = numberValue(raw.expires_at);
262
+ if (!accessToken || !expiresAt)
263
+ throw new Error("Copilot token response is incomplete");
264
+ let user = null;
265
+ try {
266
+ const userResponse = await fetcher(endpoints(domain).user, {
267
+ headers: { accept: "application/json", authorization: `token ${githubToken}`, ...HEADERS },
268
+ signal,
269
+ });
270
+ if (userResponse.ok)
271
+ user = await responseJson(userResponse, "Copilot account details");
272
+ }
273
+ catch (error) {
274
+ if (signal.aborted)
275
+ throw error;
276
+ }
277
+ const login = stringValue(user?.login);
278
+ const plan = copilotPlanName(user);
279
+ return {
280
+ accessToken,
281
+ refreshToken: githubToken,
282
+ expiresAt: expiresAt * 1000 - EXPIRY_SKEW_MS,
283
+ account: login || plan ? { id: login, label: login, plan } : undefined,
284
+ metadata: {
285
+ enterpriseDomain: domain,
286
+ baseUrl: baseUrlFromToken(accessToken, domain),
287
+ },
288
+ };
289
+ }
290
+ return {
291
+ id: "copilot",
292
+ name: "GitHub Copilot",
293
+ description: "GitHub Copilot access with automatic plan and organization detection.",
294
+ homepage: "https://github.com/features/copilot",
295
+ allowedHosts: ["api.github.com", "*.githubcopilot.com", "*.ghe.com"],
296
+ proxyBaseUrl: (credential) => stringValue(credential.metadata?.baseUrl),
297
+ loginModes: ["device"],
298
+ async startLogin(signal, options) {
299
+ const domain = normalizeDomain(options?.enterpriseDomain);
300
+ const response = await fetcher(endpoints(domain).deviceCode, {
301
+ method: "POST",
302
+ headers: {
303
+ accept: "application/json",
304
+ "content-type": "application/x-www-form-urlencoded",
305
+ ...HEADERS,
306
+ },
307
+ body: new URLSearchParams({ client_id: clientId, scope: "read:user" }),
308
+ signal,
309
+ });
310
+ const raw = await responseJson(response, "Copilot device login");
311
+ const deviceCode = stringValue(raw.device_code);
312
+ const userCode = stringValue(raw.user_code);
313
+ const verificationUri = stringValue(raw.verification_uri);
314
+ if (!deviceCode || !userCode || !verificationUri) {
315
+ throw new Error("Copilot device response is incomplete");
316
+ }
317
+ let interval = Math.max(1, numberValue(raw.interval) ?? 5) * 1000;
318
+ const expiresIn = numberValue(raw.expires_in) ?? 900;
319
+ const complete = (async () => {
320
+ const deadline = Date.now() + expiresIn * 1000;
321
+ while (Date.now() < deadline) {
322
+ await abortableDelay(interval, signal);
323
+ const tokenResponse = await fetcher(endpoints(domain).accessToken, {
324
+ method: "POST",
325
+ headers: {
326
+ accept: "application/json",
327
+ "content-type": "application/x-www-form-urlencoded",
328
+ ...HEADERS,
329
+ },
330
+ body: new URLSearchParams({
331
+ client_id: clientId,
332
+ device_code: deviceCode,
333
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
334
+ }),
335
+ signal,
336
+ });
337
+ const token = await responseJson(tokenResponse, "Copilot device authorization");
338
+ const githubToken = stringValue(token.access_token);
339
+ if (githubToken)
340
+ return exchange(githubToken, domain, signal);
341
+ const error = stringValue(token.error);
342
+ if (error === "authorization_pending")
343
+ continue;
344
+ if (error === "slow_down") {
345
+ interval += 5000;
346
+ continue;
347
+ }
348
+ throw new Error(`Copilot device authorization failed${error ? `: ${error}` : ""}`);
349
+ }
350
+ throw new Error("Copilot device authorization timed out");
351
+ })();
352
+ return {
353
+ prompt: {
354
+ mode: "device",
355
+ verificationUri,
356
+ userCode,
357
+ expiresAt: Date.now() + expiresIn * 1000,
358
+ },
359
+ complete,
360
+ };
361
+ },
362
+ async refresh(credential, signal) {
363
+ if (!credential.refreshToken)
364
+ throw new Error("GitHub token is missing");
365
+ const domain = normalizeDomain(credential.metadata?.enterpriseDomain);
366
+ return exchange(credential.refreshToken, domain, signal);
367
+ },
368
+ async authorize(request, credential) {
369
+ const baseUrl = stringValue(credential.metadata?.baseUrl);
370
+ if (!baseUrl)
371
+ throw new Error("Copilot API base URL is missing");
372
+ requireAllowedHost(request, [new URL(baseUrl).hostname]);
373
+ const raw = request.method === "POST"
374
+ ? await request
375
+ .clone()
376
+ .json()
377
+ .catch(() => null)
378
+ : null;
379
+ const authorized = bearerRequest(request, credential, HEADERS);
380
+ if (!isRecord(raw))
381
+ return authorized;
382
+ const pathname = new URL(request.url).pathname;
383
+ const isResponses = pathname.endsWith("/responses");
384
+ const auto = typeof raw.model === "string" && isAutoModel(raw.model);
385
+ const session = auto ? await routeAuto(raw, credential, request.signal) : null;
386
+ if (isResponses && raw.store === undefined)
387
+ raw.store = false;
388
+ if (isResponses && raw.store !== true && Array.isArray(raw.input)) {
389
+ raw.input = raw.input.flatMap((item) => {
390
+ if (!isRecord(item))
391
+ return [item];
392
+ if (item.type === "item_reference")
393
+ return [];
394
+ const { id: _id, ...withoutId } = item;
395
+ return [withoutId];
396
+ });
397
+ }
398
+ const entries = Array.isArray(raw.messages)
399
+ ? raw.messages
400
+ : Array.isArray(raw.input)
401
+ ? raw.input
402
+ : [];
403
+ const last = entries.at(-1);
404
+ const headers = new Headers(authorized.headers);
405
+ headers.set("x-initiator", isRecord(last) && last.role !== "user" ? "agent" : "user");
406
+ headers.set("openai-intent", "conversation-edits");
407
+ if (session) {
408
+ headers.set("copilot-session-token", session.token);
409
+ headers.set("x-github-api-version", AUTO_API_VERSION);
410
+ }
411
+ if (/"(?:image_url|input_image|image)"/.test(JSON.stringify(raw))) {
412
+ headers.set("copilot-vision-request", "true");
413
+ }
414
+ if (pathname.endsWith("/messages") && !headers.has("anthropic-beta")) {
415
+ headers.set("anthropic-beta", "interleaved-thinking-2025-05-14");
416
+ }
417
+ return new Request(authorized, { method: "POST", headers, body: JSON.stringify(raw) });
418
+ },
419
+ normalizeResponse(request, response) {
420
+ if (!new URL(request.url).pathname.endsWith("/responses") ||
421
+ !response.body ||
422
+ !response.headers.get("content-type")?.includes("text/event-stream")) {
423
+ return response;
424
+ }
425
+ const headers = new Headers(response.headers);
426
+ headers.delete("content-length");
427
+ return new Response(normalizeResponsesStream(response.body), {
428
+ status: response.status,
429
+ statusText: response.statusText,
430
+ headers,
431
+ });
432
+ },
433
+ async getUsage({ credential, signal }) {
434
+ if (!credential.refreshToken)
435
+ throw new Error("GitHub token is missing");
436
+ const domain = normalizeDomain(credential.metadata?.enterpriseDomain);
437
+ const response = await fetcher(endpoints(domain).user, {
438
+ headers: {
439
+ accept: "application/json",
440
+ authorization: `token ${credential.refreshToken}`,
441
+ ...HEADERS,
442
+ },
443
+ signal,
444
+ });
445
+ return parseCopilotUsage(await responseJson(response, "Copilot usage"));
446
+ },
447
+ async getModels({ credential, fetch, signal }) {
448
+ const baseUrl = stringValue(credential.metadata?.baseUrl);
449
+ if (!baseUrl)
450
+ throw new Error("Copilot API base URL is missing");
451
+ const response = await fetch(`${baseUrl}/models`, {
452
+ headers: {
453
+ accept: "application/json",
454
+ "x-github-api-version": "2026-06-01",
455
+ },
456
+ signal,
457
+ });
458
+ const raw = await responseJson(response, "Copilot models");
459
+ const values = Array.isArray(raw.data) ? raw.data : [];
460
+ return values.map(normalizeModel).filter((model) => Boolean(model));
461
+ },
462
+ isPermanentRefreshError(error) {
463
+ return error instanceof CopilotTokenError && (error.status === 401 || error.status === 403);
464
+ },
465
+ };
466
+ }
@@ -0,0 +1,7 @@
1
+ import type { ProviderAdapter } from "../types.js";
2
+ export interface GrokProviderOptions {
3
+ clientId?: string;
4
+ compatibilityVersion?: string;
5
+ fetch?: typeof globalThis.fetch;
6
+ }
7
+ export declare function grokProvider(options?: GrokProviderOptions): ProviderAdapter;
@@ -0,0 +1,215 @@
1
+ import { parseGrokUsage } from "../usage.js";
2
+ import { abortableDelay, bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringValue, } from "../utils.js";
3
+ const DEFAULT_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
4
+ const DEVICE_URL = "https://auth.x.ai/oauth2/device/code";
5
+ const TOKEN_URL = "https://auth.x.ai/oauth2/token";
6
+ const USAGE_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
7
+ const USER_URL = "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
8
+ const SETTINGS_URL = "https://cli-chat-proxy.grok.com/v1/settings";
9
+ const MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models";
10
+ const SCOPE = "openid profile email offline_access grok-cli:access api:access";
11
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
12
+ const EXPIRY_SKEW_MS = 2 * 60_000;
13
+ const DEFAULT_COMPATIBILITY_VERSION = "1.0.3";
14
+ class GrokTokenError extends Error {
15
+ status;
16
+ code;
17
+ constructor(status, code) {
18
+ super(`Grok token refresh failed (${code ?? status})`);
19
+ this.status = status;
20
+ this.code = code;
21
+ }
22
+ }
23
+ function credentialFromTokens(raw, previous) {
24
+ const accessToken = stringValue(raw.access_token);
25
+ const refreshToken = stringValue(raw.refresh_token) ?? previous?.refreshToken;
26
+ if (!accessToken || !refreshToken)
27
+ throw new Error("Grok token response is incomplete");
28
+ const lifetime = Math.max(60, numberValue(raw.expires_in) ?? 3600) * 1000;
29
+ return {
30
+ accessToken,
31
+ refreshToken,
32
+ expiresAt: Date.now() + Math.max(0, lifetime - EXPIRY_SKEW_MS),
33
+ };
34
+ }
35
+ export function grokProvider(options = {}) {
36
+ const clientId = options.clientId ?? DEFAULT_CLIENT_ID;
37
+ const compatibilityVersion = options.compatibilityVersion ?? DEFAULT_COMPATIBILITY_VERSION;
38
+ const fetcher = options.fetch ?? globalThis.fetch;
39
+ return {
40
+ id: "grok",
41
+ name: "Grok",
42
+ description: "xAI Grok and linked X subscription access through device authorization.",
43
+ homepage: "https://grok.com",
44
+ allowedHosts: ["cli-chat-proxy.grok.com"],
45
+ proxyBaseUrl: "https://cli-chat-proxy.grok.com/v1",
46
+ loginModes: ["device"],
47
+ async startLogin(signal) {
48
+ const response = await fetcher(DEVICE_URL, {
49
+ method: "POST",
50
+ headers: {
51
+ accept: "application/json",
52
+ "content-type": "application/x-www-form-urlencoded",
53
+ },
54
+ body: new URLSearchParams({ client_id: clientId, scope: SCOPE }),
55
+ signal,
56
+ });
57
+ const raw = await responseJson(response, "Grok device login");
58
+ const deviceCode = stringValue(raw.device_code);
59
+ const userCode = stringValue(raw.user_code);
60
+ const verificationUri = stringValue(raw.verification_uri_complete) ?? stringValue(raw.verification_uri);
61
+ if (!deviceCode || !userCode || !verificationUri) {
62
+ throw new Error("Grok device response is incomplete");
63
+ }
64
+ let interval = Math.max(1, numberValue(raw.interval) ?? 5) * 1000;
65
+ const expiresIn = Math.max(60, numberValue(raw.expires_in) ?? 300);
66
+ const complete = (async () => {
67
+ const deadline = Date.now() + expiresIn * 1000;
68
+ while (Date.now() < deadline) {
69
+ await abortableDelay(interval, signal);
70
+ const tokenResponse = await fetcher(TOKEN_URL, {
71
+ method: "POST",
72
+ headers: {
73
+ accept: "application/json",
74
+ "content-type": "application/x-www-form-urlencoded",
75
+ },
76
+ body: new URLSearchParams({
77
+ grant_type: DEVICE_GRANT,
78
+ client_id: clientId,
79
+ device_code: deviceCode,
80
+ }),
81
+ signal,
82
+ });
83
+ const token = await tokenResponse.json().catch(() => ({}));
84
+ const tokenRecord = isRecord(token) ? token : {};
85
+ if (tokenResponse.ok)
86
+ return credentialFromTokens(tokenRecord);
87
+ const error = stringValue(tokenRecord.error);
88
+ if (error === "authorization_pending")
89
+ continue;
90
+ if (error === "slow_down") {
91
+ interval += 5000;
92
+ continue;
93
+ }
94
+ throw new Error(`Grok device authorization failed${error ? `: ${error}` : ""}`);
95
+ }
96
+ throw new Error("Grok device authorization timed out");
97
+ })();
98
+ return {
99
+ prompt: {
100
+ mode: "device",
101
+ verificationUri,
102
+ userCode,
103
+ expiresAt: Date.now() + expiresIn * 1000,
104
+ },
105
+ complete,
106
+ };
107
+ },
108
+ async refresh(credential, signal) {
109
+ if (!credential.refreshToken)
110
+ throw new Error("Grok refresh token is missing");
111
+ const response = await fetcher(TOKEN_URL, {
112
+ method: "POST",
113
+ headers: {
114
+ accept: "application/json",
115
+ "content-type": "application/x-www-form-urlencoded",
116
+ },
117
+ body: new URLSearchParams({
118
+ grant_type: "refresh_token",
119
+ refresh_token: credential.refreshToken,
120
+ client_id: clientId,
121
+ }),
122
+ signal,
123
+ });
124
+ if (!response.ok) {
125
+ const raw = (await response.json().catch(() => ({})));
126
+ throw new GrokTokenError(response.status, stringValue(raw.error));
127
+ }
128
+ return credentialFromTokens(await responseJson(response, "Grok token refresh"), credential);
129
+ },
130
+ async authorize(request, credential) {
131
+ requireAllowedHost(request, ["cli-chat-proxy.grok.com"]);
132
+ const raw = request.method === "POST"
133
+ ? await request
134
+ .clone()
135
+ .json()
136
+ .catch(() => null)
137
+ : null;
138
+ const authorized = bearerRequest(request, credential, {
139
+ "x-xai-token-auth": "xai-grok-cli",
140
+ "x-grok-client-version": compatibilityVersion,
141
+ "x-grok-client-identifier": "grok-shell",
142
+ "user-agent": "xai-grok-cli",
143
+ });
144
+ if (request.method !== "POST")
145
+ return authorized;
146
+ const model = isRecord(raw) ? stringValue(raw.model) : undefined;
147
+ if (!model)
148
+ return authorized;
149
+ const headers = new Headers(authorized.headers);
150
+ headers.set("x-grok-model-override", model);
151
+ return new Request(authorized, { headers });
152
+ },
153
+ async getUsage({ fetch, signal }) {
154
+ const headers = { accept: "application/json" };
155
+ const user = await responseJson(await fetch(USER_URL, { headers, signal }), "Grok account");
156
+ const identityHeaders = {
157
+ ...headers,
158
+ ...(stringValue(user.userId) ? { "x-userid": stringValue(user.userId) } : {}),
159
+ ...(stringValue(user.email) ? { "x-email": stringValue(user.email) } : {}),
160
+ };
161
+ const [usageResponse, settingsResponse] = await Promise.all([
162
+ fetch(USAGE_URL, { headers: identityHeaders, signal }),
163
+ fetch(SETTINGS_URL, { headers: identityHeaders, signal }).catch(() => null),
164
+ ]);
165
+ const usage = await responseJson(usageResponse, "Grok usage");
166
+ const settings = settingsResponse?.ok
167
+ ? await settingsResponse.json().catch(() => null)
168
+ : null;
169
+ return parseGrokUsage(usage, user, settings);
170
+ },
171
+ async getModels({ fetch, signal }) {
172
+ const response = await fetch(MODELS_URL, {
173
+ headers: { accept: "application/json" },
174
+ signal,
175
+ });
176
+ const raw = await responseJson(response, "Grok models");
177
+ const models = Array.isArray(raw.data) ? raw.data : [];
178
+ return models.flatMap((value) => {
179
+ if (!isRecord(value))
180
+ return [];
181
+ const id = stringValue(value.id) ?? stringValue(value.model);
182
+ if (!id)
183
+ return [];
184
+ const efforts = Array.isArray(value.reasoning_efforts)
185
+ ? value.reasoning_efforts.flatMap((effort) => {
186
+ if (typeof effort === "string")
187
+ return [effort];
188
+ if (!isRecord(effort))
189
+ return [];
190
+ const name = stringValue(effort.value) ?? stringValue(effort.id);
191
+ return name ? [name] : [];
192
+ })
193
+ : undefined;
194
+ const backend = stringValue(value.api_backend);
195
+ return [
196
+ {
197
+ id,
198
+ name: stringValue(value.name),
199
+ description: stringValue(value.description),
200
+ contextWindow: numberValue(value.context_window) ?? numberValue(value.contextWindow),
201
+ maxOutputTokens: numberValue(value.max_output_tokens) ?? numberValue(value.max_completion_tokens),
202
+ reasoningEfforts: efforts?.length ? efforts : undefined,
203
+ endpoints: backend ? [backend] : undefined,
204
+ available: true,
205
+ selectable: true,
206
+ },
207
+ ];
208
+ });
209
+ },
210
+ isPermanentRefreshError(error) {
211
+ return (error instanceof GrokTokenError &&
212
+ (error.status === 401 || error.status === 403 || error.code === "invalid_grant"));
213
+ },
214
+ };
215
+ }