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,4 @@
1
+ import type { ProviderAdapter, ProviderUsageData } from "../types.js";
2
+ export declare function parseOpenCodeGoUsage(raw: unknown): ProviderUsageData | null;
3
+ export declare function openCodeGoProvider(): ProviderAdapter;
4
+ export declare function openCodeZenProvider(): ProviderAdapter;
@@ -0,0 +1,147 @@
1
+ import { bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
2
+ const API_HOST = "opencode.ai";
3
+ const API_KEY_LIFETIME_MS = 365 * 24 * 60 * 60_000;
4
+ function openCodeUsageMeter(id, label, raw) {
5
+ if (!isRecord(raw))
6
+ return null;
7
+ const percentUsed = numberValue(raw.percent);
8
+ const resetsAt = stringValue(raw.resetsAt);
9
+ if (percentUsed == null)
10
+ return null;
11
+ const resetAt = resetsAt ? Date.parse(resetsAt) : Number.NaN;
12
+ return {
13
+ id,
14
+ label,
15
+ unit: "percent",
16
+ percentUsed: Math.min(100, Math.max(0, percentUsed)),
17
+ resetAt: Number.isNaN(resetAt) ? undefined : resetAt,
18
+ };
19
+ }
20
+ export function parseOpenCodeGoUsage(raw) {
21
+ if (!isRecord(raw) || !isRecord(raw.usage))
22
+ return null;
23
+ const meters = [
24
+ openCodeUsageMeter("rolling", "5-hour limit", raw.usage.rolling),
25
+ openCodeUsageMeter("weekly", "Weekly limit", raw.usage.weekly),
26
+ openCodeUsageMeter("monthly", "Monthly limit", raw.usage.monthly),
27
+ ].filter((meter) => meter !== null);
28
+ if (!meters.length)
29
+ return null;
30
+ return {
31
+ plan: "OpenCode Go",
32
+ meters,
33
+ note: "Provider-reported quota usage. API-equivalent costs shown in chats are estimates, not charges.",
34
+ };
35
+ }
36
+ function documentedEndpoints(provider, model) {
37
+ if (/^(gpt-|grok-)/.test(model))
38
+ return ["responses"];
39
+ if (provider === "opencode-go" && /^(minimax-|qwen)/.test(model))
40
+ return ["messages"];
41
+ if (provider === "opencode-zen" && /^(claude-|qwen)/.test(model))
42
+ return ["messages"];
43
+ if (provider === "opencode-zen" && model.startsWith("gemini-"))
44
+ return [`models/${model}`];
45
+ return ["chat/completions"];
46
+ }
47
+ function openCodeProvider(config) {
48
+ const credential = (apiKey) => ({
49
+ accessToken: apiKey,
50
+ expiresAt: Date.now() + API_KEY_LIFETIME_MS,
51
+ account: { label: config.name },
52
+ });
53
+ return {
54
+ id: config.id,
55
+ name: config.name,
56
+ description: config.description,
57
+ homepage: "https://opencode.ai/auth",
58
+ allowedHosts: [API_HOST],
59
+ proxyBaseUrl: config.baseUrl,
60
+ loginModes: ["api-key"],
61
+ loginFields: [
62
+ {
63
+ name: "apiKey",
64
+ label: "API key",
65
+ description: "Create an API key at opencode.ai/auth.",
66
+ type: "password",
67
+ required: true,
68
+ },
69
+ ],
70
+ async startLogin(_signal, options) {
71
+ const apiKey = stringValue(options?.apiKey);
72
+ if (!apiKey)
73
+ throw new Error(`${config.name} API key is required`);
74
+ return {
75
+ prompt: { mode: "api-key", expiresAt: Date.now() + 60_000 },
76
+ complete: Promise.resolve(credential(apiKey)),
77
+ };
78
+ },
79
+ async refresh(current) {
80
+ return { ...current, expiresAt: Date.now() + API_KEY_LIFETIME_MS };
81
+ },
82
+ authorize(request, current) {
83
+ requireAllowedHost(request, [API_HOST]);
84
+ return bearerRequest(request, current);
85
+ },
86
+ async getModels({ fetch, signal }) {
87
+ const raw = await responseJson(await fetch(`${config.baseUrl}/models`, {
88
+ headers: { accept: "application/json" },
89
+ signal,
90
+ }), `${config.name} models`);
91
+ const values = Array.isArray(raw.data)
92
+ ? raw.data
93
+ : Array.isArray(raw.models)
94
+ ? raw.models
95
+ : [];
96
+ return values.flatMap((value) => {
97
+ if (!isRecord(value))
98
+ return [];
99
+ const id = stringValue(value.id);
100
+ return id
101
+ ? [
102
+ {
103
+ id,
104
+ name: stringValue(value.name),
105
+ description: stringValue(value.description),
106
+ endpoints: stringArray(value.supported_endpoints) ??
107
+ stringArray(value.endpoints) ??
108
+ documentedEndpoints(config.id, id),
109
+ available: true,
110
+ selectable: true,
111
+ },
112
+ ]
113
+ : [];
114
+ });
115
+ },
116
+ async getUsage({ fetch, signal }) {
117
+ if (config.id === "opencode-zen") {
118
+ return {
119
+ plan: "Pay as you go",
120
+ facts: [{ label: "Billing", value: "Metered balance" }],
121
+ note: "OpenCode does not expose Zen balance or spend through its API. Check the OpenCode console for the charged amount; chat costs are calculated from public Zen rates.",
122
+ };
123
+ }
124
+ const raw = await responseJson(await fetch(`${config.baseUrl}/usage`, {
125
+ headers: { accept: "application/json" },
126
+ signal,
127
+ }), "OpenCode Go usage");
128
+ return parseOpenCodeGoUsage(raw);
129
+ },
130
+ };
131
+ }
132
+ export function openCodeGoProvider() {
133
+ return openCodeProvider({
134
+ id: "opencode-go",
135
+ name: "OpenCode Go",
136
+ description: "OpenCode Go subscription access with an OpenCode API key.",
137
+ baseUrl: "https://opencode.ai/zen/go/v1",
138
+ });
139
+ }
140
+ export function openCodeZenProvider() {
141
+ return openCodeProvider({
142
+ id: "opencode-zen",
143
+ name: "OpenCode Zen",
144
+ description: "OpenCode Zen pay-as-you-go access with an OpenCode API key.",
145
+ baseUrl: "https://opencode.ai/zen/v1",
146
+ });
147
+ }
@@ -0,0 +1,18 @@
1
+ import type { CredentialStore, OAuthCredential, ProviderId } from "./types.js";
2
+ export declare function defaultAiSubsDataDir(): string;
3
+ export declare class FileCredentialStore implements CredentialStore {
4
+ readonly file: string;
5
+ constructor(file: string);
6
+ read(provider: ProviderId): Promise<OAuthCredential | null>;
7
+ listKeys(): Promise<string[]>;
8
+ modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
9
+ delete(provider: ProviderId): Promise<void>;
10
+ }
11
+ export declare class MemoryCredentialStore implements CredentialStore {
12
+ private readonly values;
13
+ private readonly queues;
14
+ read(provider: ProviderId): Promise<OAuthCredential | null>;
15
+ listKeys(): Promise<string[]>;
16
+ modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
17
+ delete(provider: ProviderId): Promise<void>;
18
+ }
package/dist/store.js ADDED
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { abortableDelay, isRecord } from "./utils.js";
6
+ const LOCK_TIMEOUT_MS = 15_000;
7
+ const LOCK_STALE_MS = 120_000;
8
+ export function defaultAiSubsDataDir() {
9
+ const override = process.env.AISUBS_DATA_DIR?.trim();
10
+ return override ? resolve(override) : join(homedir(), ".aisubs");
11
+ }
12
+ async function readEnvelope(file) {
13
+ try {
14
+ const parsed = JSON.parse(await readFile(file, "utf8"));
15
+ return isRecord(parsed) ? parsed : {};
16
+ }
17
+ catch (error) {
18
+ if (error.code === "ENOENT")
19
+ return {};
20
+ throw error;
21
+ }
22
+ }
23
+ async function withFileLock(path, task) {
24
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
25
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
26
+ let handle;
27
+ while (!handle) {
28
+ try {
29
+ handle = await open(path, "wx", 0o600);
30
+ }
31
+ catch (error) {
32
+ if (error.code !== "EEXIST")
33
+ throw error;
34
+ try {
35
+ if (Date.now() - (await stat(path)).mtimeMs > LOCK_STALE_MS)
36
+ await rm(path, { force: true });
37
+ }
38
+ catch { }
39
+ if (Date.now() >= deadline)
40
+ throw new Error("Timed out waiting for credential-store lock");
41
+ await abortableDelay(25);
42
+ }
43
+ }
44
+ try {
45
+ return await task();
46
+ }
47
+ finally {
48
+ await handle.close().catch(() => { });
49
+ await rm(path, { force: true }).catch(() => { });
50
+ }
51
+ }
52
+ async function writeEnvelope(file, value) {
53
+ const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
54
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
55
+ try {
56
+ await writeFile(temp, JSON.stringify(value, null, 2), { mode: 0o600 });
57
+ await rename(temp, file);
58
+ if (process.platform !== "win32")
59
+ await chmod(file, 0o600);
60
+ }
61
+ finally {
62
+ await rm(temp, { force: true }).catch(() => { });
63
+ }
64
+ }
65
+ export class FileCredentialStore {
66
+ file;
67
+ constructor(file) {
68
+ this.file = file;
69
+ }
70
+ async read(provider) {
71
+ return (await readEnvelope(this.file))[provider] ?? null;
72
+ }
73
+ async listKeys() {
74
+ return Object.keys(await readEnvelope(this.file));
75
+ }
76
+ async modify(provider, update) {
77
+ const lockId = createHash("sha256").update(provider).digest("hex");
78
+ return withFileLock(join(dirname(this.file), `${lockId}.credential.lock`), async () => {
79
+ const next = await update(await this.read(provider));
80
+ await withFileLock(`${this.file}.lock`, async () => {
81
+ const all = await readEnvelope(this.file);
82
+ if (next)
83
+ all[provider] = next;
84
+ else
85
+ delete all[provider];
86
+ await writeEnvelope(this.file, all);
87
+ });
88
+ return next;
89
+ });
90
+ }
91
+ async delete(provider) {
92
+ await this.modify(provider, () => null);
93
+ }
94
+ }
95
+ export class MemoryCredentialStore {
96
+ values = new Map();
97
+ queues = new Map();
98
+ async read(provider) {
99
+ return this.values.get(provider) ?? null;
100
+ }
101
+ async listKeys() {
102
+ return [...this.values.keys()];
103
+ }
104
+ async modify(provider, update) {
105
+ const previous = this.queues.get(provider) ?? Promise.resolve();
106
+ let result = null;
107
+ const current = previous.then(async () => {
108
+ result = await update(this.values.get(provider) ?? null);
109
+ if (result)
110
+ this.values.set(provider, result);
111
+ else
112
+ this.values.delete(provider);
113
+ });
114
+ this.queues.set(provider, current.catch(() => { }));
115
+ await current;
116
+ return result;
117
+ }
118
+ async delete(provider) {
119
+ await this.modify(provider, () => null);
120
+ }
121
+ }
@@ -0,0 +1,186 @@
1
+ export type ProviderId = "chatgpt" | "claude" | "copilot" | "grok" | (string & {});
2
+ export type LoginMode = "browser" | "device" | "external-token" | "external-cli" | "api-key";
3
+ export interface OAuthCredential {
4
+ accessToken: string;
5
+ refreshToken?: string;
6
+ expiresAt: number;
7
+ account?: {
8
+ id?: string;
9
+ label?: string;
10
+ email?: string;
11
+ plan?: string;
12
+ };
13
+ metadata?: Record<string, string | number | boolean | null>;
14
+ }
15
+ export interface CredentialStore {
16
+ read(provider: ProviderId): Promise<OAuthCredential | null>;
17
+ listKeys?(): Promise<string[]>;
18
+ modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
19
+ delete(provider: ProviderId): Promise<void>;
20
+ }
21
+ export interface UsageMeter {
22
+ id: string;
23
+ label: string;
24
+ unit: "requests" | "tokens" | "credits" | "currency" | "percent" | (string & {});
25
+ used?: number;
26
+ limit?: number;
27
+ remaining?: number;
28
+ percentUsed?: number;
29
+ included?: boolean;
30
+ unlimited?: boolean;
31
+ resetAt?: number;
32
+ window?: string;
33
+ }
34
+ export interface UsageFact {
35
+ label: string;
36
+ value: string;
37
+ }
38
+ export interface UsageResetCredit {
39
+ id?: string;
40
+ status?: string;
41
+ grantedAt?: number;
42
+ expiresAt?: number;
43
+ }
44
+ export interface UsageResetCredits {
45
+ availableCount: number;
46
+ credits?: UsageResetCredit[];
47
+ }
48
+ export interface ProviderUsageData {
49
+ plan?: string;
50
+ account?: OAuthCredential["account"];
51
+ meters?: UsageMeter[];
52
+ facts?: UsageFact[];
53
+ resetCredits?: UsageResetCredits;
54
+ note?: string;
55
+ extensions?: Record<string, unknown>;
56
+ }
57
+ export interface ProviderUsage extends ProviderUsageData {
58
+ provider: ProviderId;
59
+ accountKey: string;
60
+ asOf: number;
61
+ }
62
+ export interface ProviderUsageContext {
63
+ credential: OAuthCredential;
64
+ signal: AbortSignal;
65
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
66
+ }
67
+ export interface ProviderModel {
68
+ id: string;
69
+ name?: string;
70
+ description?: string;
71
+ contextWindow?: number;
72
+ maxOutputTokens?: number;
73
+ reasoningEfforts?: string[];
74
+ inputModalities?: string[];
75
+ endpoints?: string[];
76
+ supportsToolCall?: boolean;
77
+ available?: boolean;
78
+ selectable?: boolean;
79
+ }
80
+ export interface ProviderModels {
81
+ provider: ProviderId;
82
+ accountKey: string;
83
+ asOf: number;
84
+ models: ProviderModel[];
85
+ }
86
+ export interface CredentialSummary {
87
+ provider: ProviderId;
88
+ accountKey: string;
89
+ accessCredentialStored: boolean;
90
+ refreshCredentialStored: boolean;
91
+ automaticRefresh: boolean;
92
+ expiresAt: number;
93
+ needsRefresh: boolean;
94
+ externallyManaged: boolean;
95
+ reauthRequired?: boolean;
96
+ endpoint?: string;
97
+ account?: OAuthCredential["account"];
98
+ }
99
+ /** Safe, account-scoped data for product settings and account views. */
100
+ export interface SubscriptionAccountDetails {
101
+ session: Session;
102
+ credential: CredentialSummary;
103
+ usage: ProviderUsage | null;
104
+ models: ProviderModels | null;
105
+ }
106
+ export interface DeviceLoginPrompt {
107
+ mode: "device";
108
+ verificationUri: string;
109
+ userCode: string;
110
+ expiresAt: number;
111
+ }
112
+ export interface BrowserLoginPrompt {
113
+ mode: "browser";
114
+ authorizationUri: string;
115
+ expiresAt: number;
116
+ }
117
+ export interface ImmediateLoginPrompt {
118
+ mode: "external-token" | "external-cli" | "api-key";
119
+ expiresAt: number;
120
+ }
121
+ export type LoginPrompt = DeviceLoginPrompt | BrowserLoginPrompt | ImmediateLoginPrompt;
122
+ export interface ProviderLogin {
123
+ prompt: LoginPrompt;
124
+ complete: Promise<OAuthCredential>;
125
+ }
126
+ export interface ProviderLoginField {
127
+ name: string;
128
+ label: string;
129
+ description?: string;
130
+ placeholder?: string;
131
+ type?: "text" | "url" | "password";
132
+ required?: boolean;
133
+ }
134
+ export interface ProviderAdapter {
135
+ readonly id: ProviderId;
136
+ readonly name: string;
137
+ readonly loginModes: readonly LoginMode[];
138
+ readonly description?: string;
139
+ readonly homepage?: string;
140
+ readonly allowedHosts?: readonly string[];
141
+ readonly loginFields?: readonly ProviderLoginField[];
142
+ readonly supportsFetch?: boolean;
143
+ readonly proxyBaseUrl?: string | ((credential: OAuthCredential) => string | undefined);
144
+ /** Optional local OpenAI-compatible bridge for providers without a pass-through API. */
145
+ proxy?(request: Request, credential: OAuthCredential): Promise<Response>;
146
+ normalizeResponse?(request: Request, response: Response): Response | Promise<Response>;
147
+ startLogin(signal: AbortSignal, options?: Record<string, unknown>): Promise<ProviderLogin>;
148
+ refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential>;
149
+ authorize(request: Request, credential: OAuthCredential): Request | Promise<Request>;
150
+ getUsage?(context: ProviderUsageContext): Promise<ProviderUsageData | null>;
151
+ getModels?(context: ProviderUsageContext): Promise<ProviderModel[]>;
152
+ isPermanentRefreshError?(error: unknown): boolean;
153
+ }
154
+ export interface ProviderSummary {
155
+ id: ProviderId;
156
+ name: string;
157
+ loginModes: readonly LoginMode[];
158
+ description?: string;
159
+ homepage?: string;
160
+ allowedHosts?: readonly string[];
161
+ loginFields?: readonly ProviderLoginField[];
162
+ supportsFetch: boolean;
163
+ supportsProxy: boolean;
164
+ supportsUsage: boolean;
165
+ supportsModels: boolean;
166
+ }
167
+ export interface Session {
168
+ provider: ProviderId;
169
+ accountKey: string;
170
+ authenticated: boolean;
171
+ expiresAt?: number;
172
+ needsRefresh?: boolean;
173
+ reauthRequired?: boolean;
174
+ account?: OAuthCredential["account"];
175
+ }
176
+ export type LoginState = "pending" | "complete" | "failed" | "cancelled";
177
+ export interface LoginAttempt {
178
+ id: string;
179
+ provider: ProviderId;
180
+ accountKey: string;
181
+ prompt: LoginPrompt;
182
+ get state(): LoginState;
183
+ get error(): string | null;
184
+ wait(): Promise<Session>;
185
+ cancel(): void;
186
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ProviderUsageData } from "./types.js";
2
+ export declare function parseChatGptUsage(raw: unknown, detailedResetCredits?: unknown): ProviderUsageData | null;
3
+ export declare function copilotPlanName(raw: unknown): string | undefined;
4
+ export declare function parseCopilotUsage(raw: unknown): ProviderUsageData | null;
5
+ export declare function parseGrokUsage(raw: unknown, userRaw?: unknown, settingsRaw?: unknown): ProviderUsageData | null;