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
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, } from "./auth.js";
2
+ export { defaultAiSubsDataDir, FileCredentialStore, MemoryCredentialStore } from "./store.js";
3
+ export { chatGptProvider } from "./providers/chatgpt.js";
4
+ export { claudeProvider } from "./providers/claude.js";
5
+ export { copilotProvider } from "./providers/copilot.js";
6
+ export { grokProvider } from "./providers/grok.js";
7
+ export { openCodeGoProvider, openCodeZenProvider } from "./providers/opencode.js";
8
+ export { parseChatGptUsage, parseCopilotUsage, parseGrokUsage } from "./usage.js";
@@ -0,0 +1,7 @@
1
+ import type { ProviderAdapter } from "../types.js";
2
+ export interface ChatGptProviderOptions {
3
+ clientId?: string;
4
+ compatibilityVersion?: string;
5
+ fetch?: typeof globalThis.fetch;
6
+ }
7
+ export declare function chatGptProvider(options?: ChatGptProviderOptions): ProviderAdapter;
@@ -0,0 +1,402 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { parseChatGptUsage } from "../usage.js";
4
+ import { abortableDelay, bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
5
+ const ISSUER = "https://auth.openai.com";
6
+ const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
7
+ const TOKEN_URL = `${ISSUER}/oauth/token`;
8
+ const DEVICE_CODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`;
9
+ const DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`;
10
+ const DEVICE_REDIRECT_URI = `${ISSUER}/deviceauth/callback`;
11
+ const VERIFICATION_URL = `${ISSUER}/codex/device`;
12
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
13
+ const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
14
+ const MODELS_URL = "https://chatgpt.com/backend-api/codex/models";
15
+ const EXPIRY_SKEW_MS = 5 * 60_000;
16
+ const BROWSER_LOGIN_TIMEOUT_MS = 10 * 60_000;
17
+ const BROWSER_CALLBACK_PORTS = [1455, 1457];
18
+ class ChatGptTokenError extends Error {
19
+ code;
20
+ status;
21
+ constructor(code, status) {
22
+ super(`ChatGPT token refresh failed (${code ?? status})`);
23
+ this.code = code;
24
+ this.status = status;
25
+ }
26
+ }
27
+ function decodeJwt(token) {
28
+ if (!token)
29
+ return {};
30
+ const payload = token.split(".")[1];
31
+ if (!payload)
32
+ return {};
33
+ try {
34
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
35
+ return isRecord(parsed) ? parsed : {};
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ }
41
+ function authClaims(token) {
42
+ const value = decodeJwt(token)["https://api.openai.com/auth"];
43
+ return isRecord(value) ? value : {};
44
+ }
45
+ function expiry(accessToken) {
46
+ const exp = numberValue(decodeJwt(accessToken).exp);
47
+ return exp ? exp * 1000 - EXPIRY_SKEW_MS : Date.now() + 50 * 60_000;
48
+ }
49
+ function credentialFromTokens(raw, previous) {
50
+ const accessToken = stringValue(raw.access_token);
51
+ const refreshToken = stringValue(raw.refresh_token) ?? previous?.refreshToken;
52
+ const idToken = stringValue(raw.id_token);
53
+ const accountId = stringValue(authClaims(idToken).chatgpt_account_id) ??
54
+ stringValue(authClaims(accessToken).chatgpt_account_id) ??
55
+ previous?.account?.id;
56
+ if (!accessToken || !refreshToken || !accountId) {
57
+ throw new Error("ChatGPT token response is missing access, refresh, or account information");
58
+ }
59
+ return {
60
+ accessToken,
61
+ refreshToken,
62
+ expiresAt: expiry(accessToken),
63
+ account: {
64
+ id: accountId,
65
+ label: stringValue(decodeJwt(idToken).email) ??
66
+ previous?.account?.label ??
67
+ previous?.account?.email,
68
+ email: stringValue(decodeJwt(idToken).email) ?? previous?.account?.email,
69
+ plan: stringValue(authClaims(idToken).chatgpt_plan_type) ?? previous?.account?.plan,
70
+ },
71
+ };
72
+ }
73
+ function normalizeModel(value) {
74
+ if (!isRecord(value))
75
+ return null;
76
+ const id = stringValue(value.slug) ?? stringValue(value.id);
77
+ if (!id)
78
+ return null;
79
+ const levels = Array.isArray(value.supported_reasoning_levels)
80
+ ? value.supported_reasoning_levels.flatMap((level) => {
81
+ if (typeof level === "string")
82
+ return [level];
83
+ return isRecord(level) && stringValue(level.effort) ? [stringValue(level.effort)] : [];
84
+ })
85
+ : [];
86
+ const visibility = stringValue(value.visibility);
87
+ return {
88
+ id,
89
+ name: stringValue(value.display_name) ?? stringValue(value.name),
90
+ description: stringValue(value.description),
91
+ contextWindow: numberValue(value.context_window) ?? numberValue(value.max_context_window),
92
+ maxOutputTokens: numberValue(value.max_output_tokens),
93
+ reasoningEfforts: levels.length ? levels : stringArray(value.supported_reasoning_efforts),
94
+ inputModalities: stringArray(value.input_modalities),
95
+ available: visibility !== "hide" && value.supported_in_api !== false,
96
+ priority: numberValue(value.priority) ?? Number.MAX_SAFE_INTEGER,
97
+ };
98
+ }
99
+ export function chatGptProvider(options = {}) {
100
+ const clientId = options.clientId ?? DEFAULT_CLIENT_ID;
101
+ const compatibilityVersion = options.compatibilityVersion ?? "0.144.2";
102
+ const fetcher = options.fetch ?? globalThis.fetch;
103
+ async function startDeviceLogin(signal) {
104
+ const response = await fetcher(DEVICE_CODE_URL, {
105
+ method: "POST",
106
+ headers: { accept: "application/json", "content-type": "application/json" },
107
+ body: JSON.stringify({ client_id: clientId }),
108
+ signal,
109
+ });
110
+ const raw = await responseJson(response, "ChatGPT device login");
111
+ const deviceAuthId = stringValue(raw.device_auth_id);
112
+ const userCode = stringValue(raw.user_code) ?? stringValue(raw.usercode);
113
+ if (!deviceAuthId || !userCode)
114
+ throw new Error("ChatGPT device response is incomplete");
115
+ const interval = numberValue(raw.interval) ?? 5;
116
+ const expiresIn = numberValue(raw.expires_in) ?? 900;
117
+ const verificationUri = stringValue(raw.verification_uri_complete) ??
118
+ stringValue(raw.verification_uri) ??
119
+ VERIFICATION_URL;
120
+ const complete = (async () => {
121
+ const deadline = Date.now() + expiresIn * 1000;
122
+ while (Date.now() < deadline) {
123
+ await abortableDelay(Math.max(1, interval) * 1000, signal);
124
+ const pollResponse = await fetcher(DEVICE_TOKEN_URL, {
125
+ method: "POST",
126
+ headers: { accept: "application/json", "content-type": "application/json" },
127
+ body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
128
+ signal,
129
+ });
130
+ if (pollResponse.status === 403 || pollResponse.status === 404)
131
+ continue;
132
+ const poll = await responseJson(pollResponse, "ChatGPT device authorization");
133
+ const code = stringValue(poll.authorization_code);
134
+ const verifier = stringValue(poll.code_verifier);
135
+ if (!code || !verifier)
136
+ continue;
137
+ const tokenResponse = await fetcher(TOKEN_URL, {
138
+ method: "POST",
139
+ headers: {
140
+ accept: "application/json",
141
+ "content-type": "application/x-www-form-urlencoded",
142
+ },
143
+ body: new URLSearchParams({
144
+ grant_type: "authorization_code",
145
+ code,
146
+ redirect_uri: DEVICE_REDIRECT_URI,
147
+ client_id: clientId,
148
+ code_verifier: verifier,
149
+ }),
150
+ signal,
151
+ });
152
+ return credentialFromTokens(await responseJson(tokenResponse, "ChatGPT token exchange"));
153
+ }
154
+ throw new Error("ChatGPT device authorization timed out");
155
+ })();
156
+ return {
157
+ prompt: {
158
+ mode: "device",
159
+ verificationUri,
160
+ userCode,
161
+ expiresAt: Date.now() + expiresIn * 1000,
162
+ },
163
+ complete,
164
+ };
165
+ }
166
+ async function startBrowserLogin(signal) {
167
+ const verifier = randomBytes(32).toString("base64url");
168
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
169
+ const state = randomBytes(32).toString("base64url");
170
+ let resolveComplete;
171
+ let rejectComplete;
172
+ let settled = false;
173
+ let timer;
174
+ const complete = new Promise((resolve, reject) => {
175
+ resolveComplete = resolve;
176
+ rejectComplete = reject;
177
+ });
178
+ void complete.catch(() => undefined);
179
+ const server = createServer(async (request, response) => {
180
+ const callbackUrl = new URL(request.url ?? "/", "http://localhost");
181
+ if (callbackUrl.pathname !== "/auth/callback") {
182
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
183
+ response.end("Not found");
184
+ return;
185
+ }
186
+ const fail = (message) => {
187
+ response.writeHead(400, {
188
+ "content-type": "text/plain; charset=utf-8",
189
+ "cache-control": "no-store",
190
+ });
191
+ response.end(`ChatGPT sign-in failed: ${message}`);
192
+ finish(new Error(message));
193
+ };
194
+ if (callbackUrl.searchParams.get("state") !== state)
195
+ return fail("OAuth state did not match");
196
+ const providerError = callbackUrl.searchParams.get("error");
197
+ if (providerError)
198
+ return fail(providerError);
199
+ const code = callbackUrl.searchParams.get("code");
200
+ if (!code)
201
+ return fail("Authorization code is missing");
202
+ try {
203
+ const address = server.address();
204
+ if (!address || typeof address === "string")
205
+ throw new Error("Callback server is unavailable");
206
+ const redirectUri = `http://localhost:${address.port}/auth/callback`;
207
+ const tokenResponse = await fetcher(TOKEN_URL, {
208
+ method: "POST",
209
+ headers: {
210
+ accept: "application/json",
211
+ "content-type": "application/x-www-form-urlencoded",
212
+ },
213
+ body: new URLSearchParams({
214
+ grant_type: "authorization_code",
215
+ code,
216
+ redirect_uri: redirectUri,
217
+ client_id: clientId,
218
+ code_verifier: verifier,
219
+ }),
220
+ signal,
221
+ });
222
+ const credential = credentialFromTokens(await responseJson(tokenResponse, "ChatGPT token exchange"));
223
+ response.writeHead(200, {
224
+ "content-type": "text/plain; charset=utf-8",
225
+ "cache-control": "no-store",
226
+ });
227
+ response.end("ChatGPT connected. You can close this tab and return to AI Subs.");
228
+ finish(undefined, credential);
229
+ }
230
+ catch (error) {
231
+ fail(error instanceof Error ? error.message : String(error));
232
+ }
233
+ });
234
+ const finish = (error, credential) => {
235
+ if (settled)
236
+ return;
237
+ settled = true;
238
+ if (timer)
239
+ clearTimeout(timer);
240
+ signal.removeEventListener("abort", onAbort);
241
+ if (server.listening)
242
+ server.close();
243
+ if (error)
244
+ rejectComplete(error);
245
+ else
246
+ resolveComplete(credential);
247
+ };
248
+ const onAbort = () => finish(new Error("ChatGPT browser authorization cancelled"));
249
+ signal.addEventListener("abort", onAbort, { once: true });
250
+ let listening = false;
251
+ for (const port of BROWSER_CALLBACK_PORTS) {
252
+ try {
253
+ await new Promise((resolve, reject) => {
254
+ const onError = (error) => {
255
+ server.off("listening", onListening);
256
+ reject(error);
257
+ };
258
+ const onListening = () => {
259
+ server.off("error", onError);
260
+ resolve();
261
+ };
262
+ server.once("error", onError);
263
+ server.once("listening", onListening);
264
+ server.listen(port, "127.0.0.1");
265
+ });
266
+ listening = true;
267
+ break;
268
+ }
269
+ catch (error) {
270
+ if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) {
271
+ finish(error);
272
+ throw error;
273
+ }
274
+ }
275
+ }
276
+ if (!listening) {
277
+ const error = new Error("ChatGPT browser sign-in requires local callback port 1455 or 1457, but both are in use. Close the process using one of those ports or use device-code sign-in.");
278
+ finish(error);
279
+ throw error;
280
+ }
281
+ server.on("error", finish);
282
+ const address = server.address();
283
+ if (!address || typeof address === "string") {
284
+ finish(new Error("Unable to start ChatGPT browser callback"));
285
+ throw new Error("Unable to start ChatGPT browser callback");
286
+ }
287
+ timer = setTimeout(() => finish(new Error("ChatGPT browser authorization timed out")), BROWSER_LOGIN_TIMEOUT_MS);
288
+ timer.unref();
289
+ const redirectUri = `http://localhost:${address.port}/auth/callback`;
290
+ const authorization = new URL(`${ISSUER}/oauth/authorize`);
291
+ authorization.search = new URLSearchParams({
292
+ response_type: "code",
293
+ client_id: clientId,
294
+ redirect_uri: redirectUri,
295
+ scope: "openid profile email offline_access api.connectors.read api.connectors.invoke",
296
+ code_challenge: challenge,
297
+ code_challenge_method: "S256",
298
+ id_token_add_organizations: "true",
299
+ codex_cli_simplified_flow: "true",
300
+ state,
301
+ originator: "aisubs",
302
+ }).toString();
303
+ return {
304
+ prompt: {
305
+ mode: "browser",
306
+ authorizationUri: authorization.toString(),
307
+ expiresAt: Date.now() + BROWSER_LOGIN_TIMEOUT_MS,
308
+ },
309
+ complete,
310
+ };
311
+ }
312
+ return {
313
+ id: "chatgpt",
314
+ name: "ChatGPT",
315
+ description: "OpenAI subscription access with browser sign-in and automatic token refresh.",
316
+ homepage: "https://chatgpt.com",
317
+ allowedHosts: ["chatgpt.com"],
318
+ proxyBaseUrl: "https://chatgpt.com/backend-api/codex",
319
+ loginModes: ["browser", "device"],
320
+ startLogin: (signal, loginOptions) => loginOptions?.mode === "device" ? startDeviceLogin(signal) : startBrowserLogin(signal),
321
+ async refresh(credential, signal) {
322
+ if (!credential.refreshToken)
323
+ throw new Error("ChatGPT refresh token is missing");
324
+ const response = await fetcher(TOKEN_URL, {
325
+ method: "POST",
326
+ headers: {
327
+ accept: "application/json",
328
+ "content-type": "application/x-www-form-urlencoded",
329
+ },
330
+ body: new URLSearchParams({
331
+ client_id: clientId,
332
+ grant_type: "refresh_token",
333
+ refresh_token: credential.refreshToken,
334
+ }),
335
+ signal,
336
+ });
337
+ if (!response.ok) {
338
+ const raw = await response.json().catch(() => null);
339
+ const code = isRecord(raw)
340
+ ? (stringValue(raw.code) ??
341
+ stringValue(raw.error) ??
342
+ (isRecord(raw.error) ? stringValue(raw.error.code) : undefined))
343
+ : undefined;
344
+ throw new ChatGptTokenError(code, response.status);
345
+ }
346
+ const raw = await response.json();
347
+ if (!isRecord(raw))
348
+ throw new Error("ChatGPT refresh returned invalid JSON");
349
+ return credentialFromTokens(raw, credential);
350
+ },
351
+ authorize(request, credential) {
352
+ requireAllowedHost(request, ["chatgpt.com"]);
353
+ const accountId = credential.account?.id;
354
+ if (!accountId)
355
+ throw new Error("ChatGPT account id is missing");
356
+ return bearerRequest(request, credential, {
357
+ "chatgpt-account-id": accountId,
358
+ originator: "aisubs",
359
+ "user-agent": `aisubs/${compatibilityVersion}`,
360
+ });
361
+ },
362
+ async getUsage({ fetch, signal }) {
363
+ const response = await fetch(USAGE_URL, { headers: { accept: "application/json" }, signal });
364
+ const raw = await responseJson(response, "ChatGPT usage");
365
+ let resetCredits;
366
+ try {
367
+ const credits = await fetch(RESET_CREDITS_URL, {
368
+ headers: { accept: "application/json" },
369
+ signal,
370
+ });
371
+ if (credits.ok)
372
+ resetCredits = await credits.json();
373
+ }
374
+ catch { }
375
+ return parseChatGptUsage(raw, resetCredits);
376
+ },
377
+ async getModels({ fetch, signal }) {
378
+ const url = new URL(MODELS_URL);
379
+ url.searchParams.set("client_version", compatibilityVersion);
380
+ const response = await fetch(url, { headers: { accept: "application/json" }, signal });
381
+ const raw = await responseJson(response, "ChatGPT models");
382
+ const models = Array.isArray(raw.models) ? raw.models : [];
383
+ return models
384
+ .map(normalizeModel)
385
+ .filter((model) => Boolean(model))
386
+ .filter((model) => model.available !== false)
387
+ .sort((left, right) => left.priority - right.priority)
388
+ .map(({ priority: _priority, ...model }) => model);
389
+ },
390
+ isPermanentRefreshError(error) {
391
+ return (error instanceof ChatGptTokenError &&
392
+ (error.status === 401 ||
393
+ error.status === 403 ||
394
+ [
395
+ "invalid_grant",
396
+ "refresh_token_expired",
397
+ "refresh_token_reused",
398
+ "refresh_token_invalidated",
399
+ ].includes(error.code ?? "")));
400
+ },
401
+ };
402
+ }
@@ -0,0 +1,7 @@
1
+ import type { ProviderAdapter } from "../types.js";
2
+ export interface ClaudeProviderOptions {
3
+ clientId?: string;
4
+ compatibilityVersion?: string;
5
+ fetch?: typeof globalThis.fetch;
6
+ }
7
+ export declare function claudeProvider(options?: ClaudeProviderOptions): ProviderAdapter;