aisubs 0.3.7 → 0.3.8

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.
@@ -1,6 +1,11 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
2
3
  const API_HOST = "opencode.ai";
3
4
  const API_KEY_LIFETIME_MS = 365 * 24 * 60 * 60_000;
5
+ const DEFAULT_COMPATIBILITY_VERSION = "1.18.31";
6
+ const COMPATIBILITY_VERSION_TTL_MS = 60 * 60_000;
7
+ const ID_PATTERN = /^(ses|msg)_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
8
+ const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
4
9
  function openCodeUsageMeter(id, label, raw) {
5
10
  if (!isRecord(raw))
6
11
  return null;
@@ -33,18 +38,53 @@ export function parseOpenCodeGoUsage(raw) {
33
38
  note: "Provider-reported quota usage. API-equivalent costs shown in chats are estimates, not charges.",
34
39
  };
35
40
  }
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) {
41
+ function openCodeProvider(config, options = {}) {
42
+ const fetcher = options.fetch ?? globalThis.fetch;
43
+ let versionRequest;
44
+ let versionExpiresAt = 0;
45
+ const compatibilityVersion = () => {
46
+ if (options.compatibilityVersion)
47
+ return Promise.resolve(options.compatibilityVersion);
48
+ if (!versionRequest || Date.now() >= versionExpiresAt) {
49
+ versionExpiresAt = Date.now() + COMPATIBILITY_VERSION_TTL_MS;
50
+ versionRequest = fetcher("https://api.github.com/repos/anomalyco/opencode/releases/latest", {
51
+ headers: { accept: "application/vnd.github+json", "user-agent": "aisubs" },
52
+ })
53
+ .then(async (response) => {
54
+ if (!response.ok)
55
+ throw new Error(`OpenCode version lookup failed: ${response.status}`);
56
+ const raw = await response.json();
57
+ return isRecord(raw) ? stringValue(raw.tag_name)?.replace(/^v/, "") : undefined;
58
+ })
59
+ .then((version) => version || DEFAULT_COMPATIBILITY_VERSION)
60
+ .catch(() => DEFAULT_COMPATIBILITY_VERSION);
61
+ }
62
+ return versionRequest;
63
+ };
64
+ const routingIds = new Map();
65
+ let lastTimestamp = 0;
66
+ let counter = 0;
67
+ const identifier = (prefix, source) => {
68
+ if (source && ID_PATTERN.test(source) && source.startsWith(`${prefix}_`))
69
+ return source;
70
+ const key = source ? `${prefix}:${source}` : undefined;
71
+ const cached = key ? routingIds.get(key) : undefined;
72
+ if (cached)
73
+ return cached;
74
+ const timestamp = Date.now();
75
+ counter = timestamp === lastTimestamp ? counter + 1 : 1;
76
+ lastTimestamp = timestamp;
77
+ const encoded = BigInt(timestamp) * 0x1000n + BigInt(counter);
78
+ const time = Buffer.alloc(6);
79
+ for (let index = 0; index < time.length; index += 1) {
80
+ time[index] = Number((encoded >> BigInt(40 - 8 * index)) & 0xffn);
81
+ }
82
+ const random = [...randomBytes(14)].map((byte) => ID_CHARS[byte % ID_CHARS.length]).join("");
83
+ const value = `${prefix}_${time.toString("hex")}${random}`;
84
+ if (key)
85
+ routingIds.set(key, value);
86
+ return value;
87
+ };
48
88
  const credential = (apiKey) => ({
49
89
  accessToken: apiKey,
50
90
  expiresAt: Date.now() + API_KEY_LIFETIME_MS,
@@ -79,9 +119,15 @@ function openCodeProvider(config) {
79
119
  async refresh(current) {
80
120
  return { ...current, expiresAt: Date.now() + API_KEY_LIFETIME_MS };
81
121
  },
82
- authorize(request, current) {
122
+ async authorize(request, current) {
83
123
  requireAllowedHost(request, [API_HOST]);
84
- return bearerRequest(request, current);
124
+ const client = request.headers.get("x-opencode-client")?.trim() || "aisubs";
125
+ return bearerRequest(request, current, {
126
+ "user-agent": `opencode/latest/${await compatibilityVersion()}/${client}`,
127
+ "x-opencode-client": client,
128
+ "x-opencode-session": identifier("ses", request.headers.get("x-opencode-session")),
129
+ "x-opencode-request": identifier("msg", request.headers.get("x-opencode-request")),
130
+ });
85
131
  },
86
132
  async getModels({ fetch, signal }) {
87
133
  const raw = await responseJson(await fetch(`${config.baseUrl}/models`, {
@@ -103,9 +149,7 @@ function openCodeProvider(config) {
103
149
  id,
104
150
  name: stringValue(value.name),
105
151
  description: stringValue(value.description),
106
- endpoints: stringArray(value.supported_endpoints) ??
107
- stringArray(value.endpoints) ??
108
- documentedEndpoints(config.id, id),
152
+ endpoints: stringArray(value.supported_endpoints) ?? stringArray(value.endpoints),
109
153
  available: true,
110
154
  selectable: true,
111
155
  },
@@ -129,19 +173,19 @@ function openCodeProvider(config) {
129
173
  },
130
174
  };
131
175
  }
132
- export function openCodeGoProvider() {
176
+ export function openCodeGoProvider(options = {}) {
133
177
  return openCodeProvider({
134
178
  id: "opencode-go",
135
179
  name: "OpenCode Go",
136
180
  description: "OpenCode Go subscription access with an OpenCode API key.",
137
181
  baseUrl: "https://opencode.ai/zen/go/v1",
138
- });
182
+ }, options);
139
183
  }
140
- export function openCodeZenProvider() {
184
+ export function openCodeZenProvider(options = {}) {
141
185
  return openCodeProvider({
142
186
  id: "opencode-zen",
143
187
  name: "OpenCode Zen",
144
188
  description: "OpenCode Zen pay-as-you-go access with an OpenCode API key.",
145
189
  baseUrl: "https://opencode.ai/zen/v1",
146
- });
190
+ }, options);
147
191
  }
@@ -0,0 +1,61 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { CoordinatedCredentialStore, OAuthCredential, ProviderId, VersionedCredential } from "./types.js";
3
+ /** Node SQLite persistence. Pass a DatabaseSync to share a host-owned connection. */
4
+ export declare class SqliteCredentialStore implements CoordinatedCredentialStore {
5
+ private readonly database;
6
+ private readonly ownsConnection;
7
+ private readonly queues;
8
+ constructor(database: string | DatabaseSync);
9
+ read(provider: ProviderId): Promise<OAuthCredential | null>;
10
+ listKeys(): Promise<string[]>;
11
+ readVersioned(provider: ProviderId): Promise<VersionedCredential>;
12
+ replaceCredential(input: {
13
+ provider: ProviderId;
14
+ credential: OAuthCredential;
15
+ expectedGeneration: number;
16
+ operationId: string;
17
+ }): Promise<{
18
+ applied: boolean;
19
+ record: VersionedCredential;
20
+ }>;
21
+ deleteCredential(input: {
22
+ provider: ProviderId;
23
+ operationId: string;
24
+ }): Promise<void>;
25
+ claimRefresh(input: {
26
+ provider: ProviderId;
27
+ expectedVersion: number;
28
+ expectedGeneration: number;
29
+ claimId: string;
30
+ operationId: string;
31
+ }): Promise<"claimed" | "busy" | "changed" | "missing">;
32
+ commitRefresh(input: {
33
+ provider: ProviderId;
34
+ claimId: string;
35
+ expectedGeneration: number;
36
+ credential: OAuthCredential;
37
+ operationId: string;
38
+ }): Promise<{
39
+ applied: boolean;
40
+ record: VersionedCredential;
41
+ }>;
42
+ modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
43
+ delete(provider: ProviderId): Promise<void>;
44
+ close(): void;
45
+ private row;
46
+ private generation;
47
+ private setGeneration;
48
+ private writeCredential;
49
+ private versioned;
50
+ private operation;
51
+ private rememberOperation;
52
+ }
53
+ export declare class SqliteApiKeyStore {
54
+ private readonly database;
55
+ private readonly ownsConnection;
56
+ constructor(database: string | DatabaseSync);
57
+ readOrCreate(): Promise<string>;
58
+ regenerate(): Promise<string>;
59
+ close(): void;
60
+ private read;
61
+ }
@@ -0,0 +1,323 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { dirname } from "node:path";
3
+ import { mkdirSync } from "node:fs";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ /** Node SQLite persistence. Pass a DatabaseSync to share a host-owned connection. */
6
+ export class SqliteCredentialStore {
7
+ database;
8
+ ownsConnection;
9
+ queues = new Map();
10
+ constructor(database) {
11
+ if (typeof database === "string") {
12
+ mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
13
+ this.database = new DatabaseSync(database, { timeout: 5_000 });
14
+ this.ownsConnection = true;
15
+ }
16
+ else {
17
+ this.database = database;
18
+ this.ownsConnection = false;
19
+ }
20
+ this.database.exec(`
21
+ PRAGMA foreign_keys = ON;
22
+ CREATE TABLE IF NOT EXISTS aisubs_credentials (
23
+ account_key TEXT PRIMARY KEY,
24
+ credential_json TEXT NOT NULL,
25
+ version INTEGER NOT NULL,
26
+ updated_at INTEGER NOT NULL
27
+ ) STRICT;
28
+ CREATE TABLE IF NOT EXISTS aisubs_refresh_claims (
29
+ account_key TEXT PRIMARY KEY,
30
+ claim_id TEXT NOT NULL UNIQUE,
31
+ credential_version INTEGER NOT NULL,
32
+ account_generation INTEGER NOT NULL,
33
+ created_at INTEGER NOT NULL
34
+ ) STRICT;
35
+ CREATE TABLE IF NOT EXISTS aisubs_account_generations (
36
+ account_key TEXT PRIMARY KEY,
37
+ generation INTEGER NOT NULL,
38
+ updated_at INTEGER NOT NULL
39
+ ) STRICT;
40
+ CREATE TABLE IF NOT EXISTS aisubs_operations (
41
+ operation_id TEXT PRIMARY KEY,
42
+ result_json TEXT NOT NULL,
43
+ created_at INTEGER NOT NULL
44
+ ) STRICT;
45
+ CREATE TABLE IF NOT EXISTS aisubs_api_keys (
46
+ name TEXT PRIMARY KEY,
47
+ value TEXT NOT NULL,
48
+ updated_at INTEGER NOT NULL
49
+ ) STRICT;
50
+ `);
51
+ }
52
+ async read(provider) {
53
+ const row = this.row(provider);
54
+ return row ? JSON.parse(row.credential_json) : null;
55
+ }
56
+ async listKeys() {
57
+ return this.database.prepare("SELECT account_key FROM aisubs_credentials ORDER BY rowid").all().map(({ account_key }) => account_key);
58
+ }
59
+ async readVersioned(provider) {
60
+ const row = this.row(provider);
61
+ return {
62
+ credential: row ? JSON.parse(row.credential_json) : null,
63
+ version: row?.version ?? 0,
64
+ generation: this.generation(provider),
65
+ };
66
+ }
67
+ async replaceCredential(input) {
68
+ const replay = this.operation(input.operationId);
69
+ if (replay)
70
+ return replay;
71
+ this.database.exec("BEGIN IMMEDIATE");
72
+ try {
73
+ const generation = this.generation(input.provider);
74
+ if (generation !== input.expectedGeneration) {
75
+ const result = { applied: false, record: this.versioned(input.provider) };
76
+ this.rememberOperation(input.operationId, result);
77
+ this.database.exec("COMMIT");
78
+ return result;
79
+ }
80
+ this.writeCredential(input.provider, input.credential);
81
+ this.setGeneration(input.provider, generation + 1);
82
+ this.database
83
+ .prepare("DELETE FROM aisubs_refresh_claims WHERE account_key = ?")
84
+ .run(input.provider);
85
+ const result = { applied: true, record: this.versioned(input.provider) };
86
+ this.rememberOperation(input.operationId, result);
87
+ this.database.exec("COMMIT");
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ this.database.exec("ROLLBACK");
92
+ throw error;
93
+ }
94
+ }
95
+ async deleteCredential(input) {
96
+ if (this.operation(input.operationId))
97
+ return;
98
+ this.database.exec("BEGIN IMMEDIATE");
99
+ try {
100
+ this.database
101
+ .prepare("DELETE FROM aisubs_credentials WHERE account_key = ?")
102
+ .run(input.provider);
103
+ this.database
104
+ .prepare("DELETE FROM aisubs_refresh_claims WHERE account_key = ?")
105
+ .run(input.provider);
106
+ this.setGeneration(input.provider, this.generation(input.provider) + 1);
107
+ this.rememberOperation(input.operationId, { applied: true });
108
+ this.database.exec("COMMIT");
109
+ }
110
+ catch (error) {
111
+ this.database.exec("ROLLBACK");
112
+ throw error;
113
+ }
114
+ }
115
+ async claimRefresh(input) {
116
+ const replay = this.operation(input.operationId);
117
+ if (replay?.value)
118
+ return replay.value;
119
+ this.database.exec("BEGIN IMMEDIATE");
120
+ try {
121
+ const row = this.row(input.provider);
122
+ const generation = this.generation(input.provider);
123
+ const value = !row
124
+ ? "missing"
125
+ : row.version !== input.expectedVersion || generation !== input.expectedGeneration
126
+ ? "changed"
127
+ : this.database
128
+ .prepare("SELECT 1 FROM aisubs_refresh_claims WHERE account_key = ?")
129
+ .get(input.provider)
130
+ ? "busy"
131
+ : "claimed";
132
+ if (value === "claimed") {
133
+ this.database
134
+ .prepare(`INSERT INTO aisubs_refresh_claims(account_key, claim_id, credential_version, account_generation, created_at)
135
+ VALUES(?, ?, ?, ?, ?)`)
136
+ .run(input.provider, input.claimId, input.expectedVersion, input.expectedGeneration, Date.now());
137
+ }
138
+ this.rememberOperation(input.operationId, { value });
139
+ this.database.exec("COMMIT");
140
+ return value;
141
+ }
142
+ catch (error) {
143
+ this.database.exec("ROLLBACK");
144
+ throw error;
145
+ }
146
+ }
147
+ async commitRefresh(input) {
148
+ const replay = this.operation(input.operationId);
149
+ if (replay)
150
+ return replay;
151
+ this.database.exec("BEGIN IMMEDIATE");
152
+ try {
153
+ const claim = this.database
154
+ .prepare(`SELECT account_generation FROM aisubs_refresh_claims
155
+ WHERE account_key = ? AND claim_id = ?`)
156
+ .get(input.provider, input.claimId);
157
+ const applied = Boolean(claim &&
158
+ claim.account_generation === input.expectedGeneration &&
159
+ this.generation(input.provider) === input.expectedGeneration);
160
+ if (applied) {
161
+ this.writeCredential(input.provider, input.credential);
162
+ this.database
163
+ .prepare("DELETE FROM aisubs_refresh_claims WHERE account_key = ? AND claim_id = ?")
164
+ .run(input.provider, input.claimId);
165
+ }
166
+ const result = { applied, record: this.versioned(input.provider) };
167
+ this.rememberOperation(input.operationId, result);
168
+ this.database.exec("COMMIT");
169
+ return result;
170
+ }
171
+ catch (error) {
172
+ this.database.exec("ROLLBACK");
173
+ throw error;
174
+ }
175
+ }
176
+ async modify(provider, update) {
177
+ const previous = this.queues.get(provider) ?? Promise.resolve();
178
+ let result = null;
179
+ const current = previous.then(async () => {
180
+ for (;;) {
181
+ const observed = this.row(provider);
182
+ result = await update(observed ? JSON.parse(observed.credential_json) : null);
183
+ this.database.exec("BEGIN IMMEDIATE");
184
+ try {
185
+ const latest = this.row(provider);
186
+ if ((latest?.version ?? 0) !== (observed?.version ?? 0)) {
187
+ this.database.exec("ROLLBACK");
188
+ continue;
189
+ }
190
+ if (result) {
191
+ this.database
192
+ .prepare(`INSERT INTO aisubs_credentials(account_key, credential_json, version, updated_at)
193
+ VALUES(?, ?, 1, ?)
194
+ ON CONFLICT(account_key) DO UPDATE SET credential_json = excluded.credential_json,
195
+ version = aisubs_credentials.version + 1, updated_at = excluded.updated_at`)
196
+ .run(provider, JSON.stringify(result), Date.now());
197
+ }
198
+ else {
199
+ this.database
200
+ .prepare("DELETE FROM aisubs_credentials WHERE account_key = ?")
201
+ .run(provider);
202
+ }
203
+ this.database.exec("COMMIT");
204
+ return;
205
+ }
206
+ catch (error) {
207
+ this.database.exec("ROLLBACK");
208
+ throw error;
209
+ }
210
+ }
211
+ });
212
+ const settled = current.catch(() => { });
213
+ this.queues.set(provider, settled);
214
+ try {
215
+ await current;
216
+ }
217
+ finally {
218
+ if (this.queues.get(provider) === settled)
219
+ this.queues.delete(provider);
220
+ }
221
+ return result;
222
+ }
223
+ async delete(provider) {
224
+ await this.modify(provider, () => null);
225
+ }
226
+ close() {
227
+ if (this.ownsConnection)
228
+ this.database.close();
229
+ }
230
+ row(provider) {
231
+ return this.database
232
+ .prepare("SELECT credential_json, version FROM aisubs_credentials WHERE account_key = ?")
233
+ .get(provider);
234
+ }
235
+ generation(provider) {
236
+ const row = this.database
237
+ .prepare("SELECT generation FROM aisubs_account_generations WHERE account_key = ?")
238
+ .get(provider);
239
+ return row?.generation ?? 0;
240
+ }
241
+ setGeneration(provider, generation) {
242
+ this.database
243
+ .prepare(`INSERT INTO aisubs_account_generations(account_key, generation, updated_at) VALUES(?, ?, ?)
244
+ ON CONFLICT(account_key) DO UPDATE SET generation = excluded.generation, updated_at = excluded.updated_at`)
245
+ .run(provider, generation, Date.now());
246
+ }
247
+ writeCredential(provider, credential) {
248
+ this.database
249
+ .prepare(`INSERT INTO aisubs_credentials(account_key, credential_json, version, updated_at)
250
+ VALUES(?, ?, 1, ?)
251
+ ON CONFLICT(account_key) DO UPDATE SET credential_json = excluded.credential_json,
252
+ version = aisubs_credentials.version + 1, updated_at = excluded.updated_at`)
253
+ .run(provider, JSON.stringify(credential), Date.now());
254
+ }
255
+ versioned(provider) {
256
+ const row = this.row(provider);
257
+ return {
258
+ credential: row ? JSON.parse(row.credential_json) : null,
259
+ version: row?.version ?? 0,
260
+ generation: this.generation(provider),
261
+ };
262
+ }
263
+ operation(operationId) {
264
+ const row = this.database
265
+ .prepare("SELECT result_json FROM aisubs_operations WHERE operation_id = ?")
266
+ .get(operationId);
267
+ return row ? JSON.parse(row.result_json) : undefined;
268
+ }
269
+ rememberOperation(operationId, result) {
270
+ this.database
271
+ .prepare("INSERT OR IGNORE INTO aisubs_operations(operation_id, result_json, created_at) VALUES(?, ?, ?)")
272
+ .run(operationId, JSON.stringify(result), Date.now());
273
+ }
274
+ }
275
+ export class SqliteApiKeyStore {
276
+ database;
277
+ ownsConnection;
278
+ constructor(database) {
279
+ if (typeof database === "string") {
280
+ mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
281
+ this.database = new DatabaseSync(database, { timeout: 5_000 });
282
+ this.ownsConnection = true;
283
+ }
284
+ else {
285
+ this.database = database;
286
+ this.ownsConnection = false;
287
+ }
288
+ this.database.exec(`CREATE TABLE IF NOT EXISTS aisubs_api_keys (
289
+ name TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
290
+ ) STRICT;`);
291
+ }
292
+ async readOrCreate() {
293
+ const existing = this.read();
294
+ if (existing)
295
+ return existing;
296
+ const value = apiKey();
297
+ this.database
298
+ .prepare("INSERT OR IGNORE INTO aisubs_api_keys(name, value, updated_at) VALUES('default', ?, ?)")
299
+ .run(value, Date.now());
300
+ return this.read() ?? value;
301
+ }
302
+ async regenerate() {
303
+ const value = apiKey();
304
+ this.database
305
+ .prepare(`INSERT INTO aisubs_api_keys(name, value, updated_at) VALUES('default', ?, ?)
306
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
307
+ .run(value, Date.now());
308
+ return value;
309
+ }
310
+ close() {
311
+ if (this.ownsConnection)
312
+ this.database.close();
313
+ }
314
+ read() {
315
+ const row = this.database
316
+ .prepare("SELECT value FROM aisubs_api_keys WHERE name = 'default'")
317
+ .get();
318
+ return row?.value ?? null;
319
+ }
320
+ }
321
+ function apiKey() {
322
+ return `aisubs_${randomBytes(32).toString("base64url")}_${randomUUID().slice(0, 8)}`;
323
+ }
package/dist/store.d.ts CHANGED
@@ -1,24 +1 @@
1
- import type { CredentialStore, OAuthCredential, ProviderId } from "./types.js";
2
1
  export declare function defaultAiSubsDataDir(): string;
3
- export declare class FileApiKeyStore {
4
- readonly file: string;
5
- constructor(file: string);
6
- readOrCreate(): Promise<string>;
7
- regenerate(): Promise<string>;
8
- }
9
- export declare class FileCredentialStore implements CredentialStore {
10
- readonly file: string;
11
- constructor(file: string);
12
- read(provider: ProviderId): Promise<OAuthCredential | null>;
13
- listKeys(): Promise<string[]>;
14
- modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
15
- delete(provider: ProviderId): Promise<void>;
16
- }
17
- export declare class MemoryCredentialStore implements CredentialStore {
18
- private readonly values;
19
- private readonly queues;
20
- read(provider: ProviderId): Promise<OAuthCredential | null>;
21
- listKeys(): Promise<string[]>;
22
- modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
23
- delete(provider: ProviderId): Promise<void>;
24
- }