gh-multi 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 (53) hide show
  1. package/README.md +70 -0
  2. package/gh-multi-dist/constants.d.ts +14 -0
  3. package/gh-multi-dist/constants.js +16 -0
  4. package/gh-multi-dist/constants.js.map +1 -0
  5. package/gh-multi-dist/index.d.ts +4 -0
  6. package/gh-multi-dist/index.js +110 -0
  7. package/gh-multi-dist/index.js.map +1 -0
  8. package/gh-multi-dist/plugin/accounts.d.ts +62 -0
  9. package/gh-multi-dist/plugin/accounts.js +703 -0
  10. package/gh-multi-dist/plugin/accounts.js.map +1 -0
  11. package/gh-multi-dist/plugin/cli.d.ts +19 -0
  12. package/gh-multi-dist/plugin/cli.js +409 -0
  13. package/gh-multi-dist/plugin/cli.js.map +1 -0
  14. package/gh-multi-dist/plugin/config/index.d.ts +2 -0
  15. package/gh-multi-dist/plugin/config/index.js +3 -0
  16. package/gh-multi-dist/plugin/config/index.js.map +1 -0
  17. package/gh-multi-dist/plugin/config/loader.d.ts +3 -0
  18. package/gh-multi-dist/plugin/config/loader.js +78 -0
  19. package/gh-multi-dist/plugin/config/loader.js.map +1 -0
  20. package/gh-multi-dist/plugin/config/schema.d.ts +28 -0
  21. package/gh-multi-dist/plugin/config/schema.js +10 -0
  22. package/gh-multi-dist/plugin/config/schema.js.map +1 -0
  23. package/gh-multi-dist/plugin/device_flow.d.ts +18 -0
  24. package/gh-multi-dist/plugin/device_flow.js +78 -0
  25. package/gh-multi-dist/plugin/device_flow.js.map +1 -0
  26. package/gh-multi-dist/plugin/fetch.d.ts +3 -0
  27. package/gh-multi-dist/plugin/fetch.js +156 -0
  28. package/gh-multi-dist/plugin/fetch.js.map +1 -0
  29. package/gh-multi-dist/plugin/index.d.ts +5 -0
  30. package/gh-multi-dist/plugin/index.js +6 -0
  31. package/gh-multi-dist/plugin/index.js.map +1 -0
  32. package/gh-multi-dist/plugin/logger.d.ts +43 -0
  33. package/gh-multi-dist/plugin/logger.js +169 -0
  34. package/gh-multi-dist/plugin/logger.js.map +1 -0
  35. package/gh-multi-dist/plugin/models.d.ts +2 -0
  36. package/gh-multi-dist/plugin/models.js +96 -0
  37. package/gh-multi-dist/plugin/models.js.map +1 -0
  38. package/gh-multi-dist/plugin/quota.d.ts +11 -0
  39. package/gh-multi-dist/plugin/quota.js +42 -0
  40. package/gh-multi-dist/plugin/quota.js.map +1 -0
  41. package/gh-multi-dist/plugin/token.d.ts +5 -0
  42. package/gh-multi-dist/plugin/token.js +7 -0
  43. package/gh-multi-dist/plugin/token.js.map +1 -0
  44. package/gh-multi-dist/plugin/ui/ansi.d.ts +41 -0
  45. package/gh-multi-dist/plugin/ui/ansi.js +67 -0
  46. package/gh-multi-dist/plugin/ui/ansi.js.map +1 -0
  47. package/gh-multi-dist/plugin/ui/confirm.d.ts +1 -0
  48. package/gh-multi-dist/plugin/ui/confirm.js +15 -0
  49. package/gh-multi-dist/plugin/ui/confirm.js.map +1 -0
  50. package/gh-multi-dist/plugin/ui/select.d.ts +13 -0
  51. package/gh-multi-dist/plugin/ui/select.js +170 -0
  52. package/gh-multi-dist/plugin/ui/select.js.map +1 -0
  53. package/package.json +52 -0
@@ -0,0 +1,703 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { rename, writeFile, mkdir, readFile, copyFile, unlink } from "fs/promises";
4
+ import { tmpdir } from "os";
5
+ import { xdgConfig } from "xdg-basedir";
6
+ import { lock } from "proper-lockfile";
7
+ import { createLogger } from "./logger.js";
8
+ import { fetchQuota } from "./quota.js";
9
+ import { isModelAvailableForSku, getRequiredTierForModel } from "./models.js";
10
+ import { OPENCODE_USER_AGENT, MAX_HEALTH, INITIAL_HEALTH, MIN_HEALTH_THRESHOLD, DEFAULT_RATE_LIMIT_COOLDOWN, MAX_FAILURES_BEFORE_SUSPEND, SESSION_CLEANUP_INTERVAL_MS, QUOTA_SYNC_INTERVAL_MS, SYNC_EVERY_N_REQUESTS } from "../constants.js";
11
+ const log = createLogger('accounts');
12
+ const CURRENT_VERSION = 1;
13
+ function mergeAccountStorage(inMemory, onDisk) {
14
+ const merged = new Map();
15
+ for (const acc of onDisk) {
16
+ merged.set(acc.username, acc);
17
+ }
18
+ for (const acc of inMemory) {
19
+ const existing = merged.get(acc.username);
20
+ if (existing) {
21
+ const mergedAccount = {
22
+ ...existing,
23
+ ...acc,
24
+ lastUsed: Math.max(existing.lastUsed || 0, acc.lastUsed || 0),
25
+ health_score: (acc.lastUsed || 0) > (existing.lastUsed || 0)
26
+ ? acc.health_score
27
+ : existing.health_score,
28
+ rateLimitedUntil: Math.max(existing.rateLimitedUntil || 0, acc.rateLimitedUntil || 0),
29
+ };
30
+ if (acc.lastQuotaSync && existing.lastQuotaSync) {
31
+ if (existing.lastQuotaSync > acc.lastQuotaSync) {
32
+ mergedAccount.premiumRemaining = existing.premiumRemaining;
33
+ mergedAccount.premiumTotal = existing.premiumTotal;
34
+ mergedAccount.lastQuotaSync = existing.lastQuotaSync;
35
+ mergedAccount.quotaResetDate = existing.quotaResetDate;
36
+ }
37
+ }
38
+ merged.set(acc.username, mergedAccount);
39
+ }
40
+ else {
41
+ merged.set(acc.username, acc);
42
+ }
43
+ }
44
+ return Array.from(merged.values());
45
+ }
46
+ async function atomicWriteFile(filePath, data) {
47
+ const dir = path.dirname(filePath);
48
+ await mkdir(dir, { recursive: true });
49
+ const tempPath = path.join(tmpdir(), `copilot-${Date.now()}-${Math.random().toString(36)}.tmp`);
50
+ await writeFile(tempPath, data, { mode: 0o600 });
51
+ try {
52
+ await rename(tempPath, filePath);
53
+ }
54
+ catch (error) {
55
+ if (error.code === 'EXDEV') {
56
+ await copyFile(tempPath, filePath);
57
+ await unlink(tempPath);
58
+ }
59
+ else {
60
+ await unlink(tempPath).catch(() => { });
61
+ throw error;
62
+ }
63
+ }
64
+ }
65
+ function getConfigDir() {
66
+ const dir = process.env.OPENCODE_CONFIG_DIR ||
67
+ (xdgConfig
68
+ ? path.join(xdgConfig, "opencode")
69
+ : path.join(process.env.HOME || "", ".config", "opencode"));
70
+ return dir;
71
+ }
72
+ export class CopilotAccountManager {
73
+ accounts = [];
74
+ config;
75
+ sessionIdToUsername = new Map();
76
+ sessionCleanupInterval = null;
77
+ dataDir;
78
+ saveTimeout = null;
79
+ quotaSyncInterval = null;
80
+ requestCount = 0;
81
+ constructor(config, dataDir) {
82
+ this.config = config;
83
+ this.dataDir = dataDir;
84
+ this.startQuotaSync();
85
+ this.startSessionCleanup();
86
+ }
87
+ async shutdown() {
88
+ this.stopQuotaSync();
89
+ this.stopSessionCleanup();
90
+ if (this.saveTimeout) {
91
+ clearTimeout(this.saveTimeout);
92
+ this.saveTimeout = null;
93
+ await this.saveAccounts();
94
+ }
95
+ }
96
+ startQuotaSync() {
97
+ if (this.quotaSyncInterval)
98
+ return;
99
+ this.quotaSyncInterval = setInterval(() => {
100
+ this.syncAllQuotas().catch((err) => {
101
+ log.error("accounts", "Failed to run periodic quota sync", err);
102
+ });
103
+ }, QUOTA_SYNC_INTERVAL_MS);
104
+ this.quotaSyncInterval.unref();
105
+ }
106
+ stopQuotaSync() {
107
+ if (this.quotaSyncInterval) {
108
+ clearInterval(this.quotaSyncInterval);
109
+ this.quotaSyncInterval = null;
110
+ }
111
+ }
112
+ startSessionCleanup() {
113
+ if (this.sessionCleanupInterval)
114
+ return;
115
+ this.sessionCleanupInterval = setInterval(() => {
116
+ this.cleanupStaleSessions();
117
+ }, SESSION_CLEANUP_INTERVAL_MS);
118
+ this.sessionCleanupInterval.unref();
119
+ }
120
+ stopSessionCleanup() {
121
+ if (this.sessionCleanupInterval) {
122
+ clearInterval(this.sessionCleanupInterval);
123
+ this.sessionCleanupInterval = null;
124
+ }
125
+ }
126
+ cleanupStaleSessions() {
127
+ const TTL_MS = 30 * 60 * 1000;
128
+ const now = Date.now();
129
+ for (const [sessionId, data] of this.sessionIdToUsername) {
130
+ if (now - data.addedAt > TTL_MS) {
131
+ this.sessionIdToUsername.delete(sessionId);
132
+ }
133
+ }
134
+ }
135
+ async syncAllQuotas() {
136
+ const activeAccounts = this.accounts.filter((a) => a.status !== "suspended" && a.enabled !== false);
137
+ let anyUpdated = false;
138
+ for (const account of activeAccounts) {
139
+ await new Promise((resolve) => setTimeout(resolve, 150));
140
+ const updated = await this.syncQuotaForAccount(account, false);
141
+ if (updated)
142
+ anyUpdated = true;
143
+ }
144
+ if (anyUpdated) {
145
+ this.requestSave();
146
+ }
147
+ }
148
+ async syncQuotaForAccount(account, shouldSave = true) {
149
+ try {
150
+ const quota = await fetchQuota(account.oauthToken);
151
+ if (quota) {
152
+ account.quotaResetDate = quota.resetDate || undefined;
153
+ account.lastQuotaSync = Date.now();
154
+ if (quota.premium) {
155
+ account.premiumRemaining = quota.premium.remaining;
156
+ account.premiumTotal = quota.premium.total;
157
+ }
158
+ if (shouldSave) {
159
+ this.requestSave();
160
+ }
161
+ return true;
162
+ }
163
+ }
164
+ catch (error) {
165
+ log.warn(`[ACCOUNTS] [QUOTA] Failed to sync quota for ${account.username}`, { error: error.message });
166
+ }
167
+ return false;
168
+ }
169
+ hasAccounts() {
170
+ return this.accounts.length > 0;
171
+ }
172
+ hasHealthyAccounts() {
173
+ return this.accounts.some((a) => a.status !== "suspended" && a.enabled !== false);
174
+ }
175
+ async loadAccounts() {
176
+ try {
177
+ const configDir = this.dataDir || getConfigDir();
178
+ const accountsFile = path.join(configDir, "copilot-accounts.json");
179
+ if (process.env.NODE_ENV === "test") {
180
+ const isRealConfig = accountsFile.includes(process.env.HOME || "---never-match---") &&
181
+ !accountsFile.includes("tmp") &&
182
+ !accountsFile.includes("temp");
183
+ if (isRealConfig && !this.dataDir) {
184
+ log.error("[ACCOUNTS] [LOAD] Test pollution detected! Attempting to load accounts from real config: " +
185
+ accountsFile);
186
+ throw new Error("Test pollution protection: Attempting to load accounts from real config during tests");
187
+ }
188
+ }
189
+ try {
190
+ const content = await readFile(accountsFile, "utf-8");
191
+ const rawData = JSON.parse(content);
192
+ let data;
193
+ if (rawData && rawData.version === CURRENT_VERSION && Array.isArray(rawData.accounts)) {
194
+ data = rawData;
195
+ }
196
+ else {
197
+ log.warn("[ACCOUNTS] [LOAD] Invalid or legacy storage format, starting fresh.");
198
+ data = { version: CURRENT_VERSION, accounts: [] };
199
+ }
200
+ this.accounts = (data.accounts || []).map((account) => ({
201
+ ...account,
202
+ status: account.status || "active",
203
+ enabled: account.enabled ?? true,
204
+ health_score: account.health_score ?? INITIAL_HEALTH,
205
+ failureCount: account.failureCount || 0,
206
+ rateLimitedUntil: account.rateLimitedUntil || 0,
207
+ lastUsed: account.lastUsed || 0,
208
+ }));
209
+ }
210
+ catch (err) {
211
+ if (err.code !== "ENOENT") {
212
+ throw err;
213
+ }
214
+ }
215
+ }
216
+ catch (error) {
217
+ log.error("[ACCOUNTS] [LOAD] Failed to load accounts:", error);
218
+ this.accounts = [];
219
+ }
220
+ // NON-BLOCKING: Fire-and-forget quota sync to allow immediate plugin load.
221
+ // We do NOT await this.
222
+ this.syncAllQuotas().catch((err) => {
223
+ log.error("[ACCOUNTS] [QUOTA] Failed to run initial quota sync", err);
224
+ });
225
+ }
226
+ async addAccount(oauthToken) {
227
+ const userResponse = await fetch("https://api.github.com/user", {
228
+ headers: {
229
+ Authorization: `token ${oauthToken}`,
230
+ "User-Agent": OPENCODE_USER_AGENT,
231
+ },
232
+ });
233
+ if (!userResponse.ok) {
234
+ throw new Error(`Failed to fetch user info: ${userResponse.statusText}`);
235
+ }
236
+ const userData = (await userResponse.json());
237
+ const username = userData.login;
238
+ let sku;
239
+ let premiumRemaining;
240
+ let premiumTotal;
241
+ let quotaResetDate;
242
+ let lastQuotaSync;
243
+ try {
244
+ const copilotResponse = await fetch("https://api.github.com/copilot_internal/user", {
245
+ headers: {
246
+ Authorization: `Bearer ${oauthToken}`,
247
+ "User-Agent": OPENCODE_USER_AGENT,
248
+ },
249
+ });
250
+ if (copilotResponse.ok) {
251
+ const copilotData = (await copilotResponse.json());
252
+ sku = copilotData.access_type_sku;
253
+ if (sku === "free_limited_copilot") {
254
+ }
255
+ else if (sku === "no_access") {
256
+ log.warn(`[ACCOUNTS] [NO_ACCESS] Account ${username} has no Copilot access. Please enable at github.com/settings/copilot`);
257
+ }
258
+ else {
259
+ }
260
+ log.info(`[ACCOUNTS] [ADD] Detected Copilot SKU for ${username}: ${sku}`);
261
+ }
262
+ else {
263
+ log.warn(`[ACCOUNTS] [ADD] Failed to fetch Copilot user info: ${copilotResponse.status} ${copilotResponse.statusText}`);
264
+ }
265
+ }
266
+ catch (error) {
267
+ log.error("[ACCOUNTS] [ADD] Error fetching Copilot user info:", error);
268
+ }
269
+ try {
270
+ const quota = await fetchQuota(oauthToken);
271
+ if (quota) {
272
+ if (quota.premium) {
273
+ premiumRemaining = quota.premium.remaining;
274
+ premiumTotal = quota.premium.total;
275
+ }
276
+ quotaResetDate = quota.resetDate || undefined;
277
+ lastQuotaSync = Date.now();
278
+ log.info(`[ACCOUNTS] [ADD] Fetched quota for ${username}: Reset=${quotaResetDate} | Premium: ${premiumRemaining}/${premiumTotal}`);
279
+ }
280
+ }
281
+ catch (e) {
282
+ log.error(`[ACCOUNTS] [ADD] Failed to fetch quota for ${username}`, e);
283
+ }
284
+ const isNoAccess = sku === "no_access";
285
+ const newAccount = {
286
+ username,
287
+ oauthToken,
288
+ addedAt: Date.now(),
289
+ status: isNoAccess ? "suspended" : "active",
290
+ enabled: !isNoAccess,
291
+ health_score: INITIAL_HEALTH,
292
+ failureCount: 0,
293
+ rateLimitedUntil: 0,
294
+ lastUsed: 0,
295
+ sku,
296
+ premiumRemaining,
297
+ premiumTotal,
298
+ quotaResetDate,
299
+ lastQuotaSync,
300
+ };
301
+ this.accounts = this.accounts.filter((a) => a.username !== username);
302
+ this.accounts.push(newAccount);
303
+ await this.saveAccounts();
304
+ return newAccount;
305
+ }
306
+ async refreshAccountInfo(username) {
307
+ const account = this.accounts.find((a) => a.username === username);
308
+ if (!account)
309
+ return null;
310
+ try {
311
+ const copilotResponse = await fetch("https://api.github.com/copilot_internal/user", {
312
+ headers: {
313
+ Authorization: `Bearer ${account.oauthToken}`,
314
+ "User-Agent": OPENCODE_USER_AGENT,
315
+ },
316
+ });
317
+ if (copilotResponse.ok) {
318
+ const copilotData = (await copilotResponse.json());
319
+ account.sku = copilotData.access_type_sku;
320
+ if (account.sku === "free_limited_copilot") {
321
+ }
322
+ else if (account.sku === "no_access") {
323
+ account.status = "suspended";
324
+ account.enabled = false;
325
+ log.warn(`[ACCOUNTS] [NO_ACCESS] Account ${username} has no Copilot access. Please enable at github.com/settings/copilot`);
326
+ }
327
+ else {
328
+ if (account.status === "suspended" &&
329
+ account.enabled === false) {
330
+ log.info(`[ACCOUNTS] [RECOVER] Account ${username} now has access (SKU: ${account.sku}). Re-enabling.`);
331
+ account.status = "active";
332
+ account.enabled = true;
333
+ }
334
+ }
335
+ log.info(`[ACCOUNTS] [REFRESH] Refreshed Copilot info for ${username}: SKU=${account.sku}`);
336
+ try {
337
+ const quota = await fetchQuota(account.oauthToken);
338
+ if (quota) {
339
+ if (quota.premium) {
340
+ account.premiumRemaining = quota.premium.remaining;
341
+ account.premiumTotal = quota.premium.total;
342
+ }
343
+ account.quotaResetDate = quota.resetDate || undefined;
344
+ account.lastQuotaSync = Date.now();
345
+ log.info(`[ACCOUNTS] [REFRESH] Refreshed quota for ${username}: Premium=${account.premiumRemaining}/${account.premiumTotal}`);
346
+ }
347
+ }
348
+ catch (e) {
349
+ log.warn(`[ACCOUNTS] [REFRESH] Failed to refresh quota for ${username}`, { error: e.message });
350
+ }
351
+ await this.saveAccounts();
352
+ }
353
+ }
354
+ catch (error) {
355
+ log.error(`[ACCOUNTS] [REFRESH] Failed to refresh account info for ${username}:`, error);
356
+ }
357
+ return account;
358
+ }
359
+ requestSave() {
360
+ if (this.saveTimeout) {
361
+ clearTimeout(this.saveTimeout);
362
+ }
363
+ this.saveTimeout = setTimeout(async () => {
364
+ this.saveTimeout = null;
365
+ try {
366
+ await this.saveAccounts();
367
+ }
368
+ catch (err) {
369
+ log.error("[ACCOUNTS] [SAVE] Failed to save accounts in background", err);
370
+ }
371
+ }, 300);
372
+ }
373
+ async saveAccounts(accountsToRemove = []) {
374
+ const configDir = this.dataDir || getConfigDir();
375
+ const accountsFile = path.join(configDir, "copilot-accounts.json");
376
+ if (process.env.NODE_ENV === "test") {
377
+ const isRealConfig = accountsFile.includes(process.env.HOME || "---never-match---") &&
378
+ !accountsFile.includes("tmp") &&
379
+ !accountsFile.includes("temp");
380
+ if (isRealConfig && !this.dataDir) {
381
+ log.error("[ACCOUNTS] [SAVE] Test pollution detected! Attempting to save accounts to real config: " +
382
+ accountsFile);
383
+ throw new Error("Test pollution protection: Attempting to save accounts to real config during tests");
384
+ }
385
+ }
386
+ const fileDir = path.dirname(accountsFile);
387
+ await mkdir(fileDir, { recursive: true });
388
+ if (!fs.existsSync(accountsFile)) {
389
+ await writeFile(accountsFile, JSON.stringify({ version: CURRENT_VERSION, accounts: [] }), { mode: 0o600 });
390
+ }
391
+ let release;
392
+ release = await lock(accountsFile, { retries: 5 });
393
+ try {
394
+ let onDisk = [];
395
+ try {
396
+ if (fs.existsSync(accountsFile)) {
397
+ const content = await readFile(accountsFile, "utf-8");
398
+ const raw = JSON.parse(content);
399
+ let data;
400
+ if (raw && raw.version === CURRENT_VERSION && Array.isArray(raw.accounts)) {
401
+ data = raw;
402
+ }
403
+ else {
404
+ data = { version: CURRENT_VERSION, accounts: [] };
405
+ }
406
+ onDisk = data.accounts || [];
407
+ }
408
+ }
409
+ catch (e) {
410
+ log.warn("[ACCOUNTS] [SAVE] Failed to read existing accounts for merge", { error: e });
411
+ }
412
+ if (accountsToRemove.length > 0) {
413
+ onDisk = onDisk.filter((a) => !accountsToRemove.includes(a.username));
414
+ }
415
+ const merged = mergeAccountStorage(this.accounts, onDisk);
416
+ this.accounts = merged;
417
+ const data = {
418
+ version: CURRENT_VERSION,
419
+ accounts: merged,
420
+ };
421
+ await atomicWriteFile(accountsFile, JSON.stringify(data, null, 2));
422
+ log.info(`[ACCOUNTS] [SAVE] Successfully saved ${this.accounts.length} accounts to disk`);
423
+ }
424
+ finally {
425
+ await release();
426
+ }
427
+ }
428
+ async getCurrentOrNext(sessionId, model) {
429
+ const account = this.selectNextAccount(sessionId, model);
430
+ if (!account) {
431
+ const rateLimitedAccounts = this.accounts.filter((a) => a.status === "rate_limited" &&
432
+ a.enabled !== false &&
433
+ a.rateLimitedUntil &&
434
+ a.rateLimitedUntil > Date.now());
435
+ if (rateLimitedAccounts.length > 0) {
436
+ rateLimitedAccounts.sort((a, b) => (a.rateLimitedUntil || 0) - (b.rateLimitedUntil || 0));
437
+ const soonest = rateLimitedAccounts[0];
438
+ const waitTime = (soonest.rateLimitedUntil || 0) - Date.now();
439
+ const maxWait = 300 * 1000;
440
+ log.info(`[ACCOUNTS] [RATELIMIT] All accounts rate limited. Next available: ${soonest.username} in ${waitTime}ms`);
441
+ if (waitTime > 0 && waitTime < maxWait) {
442
+ await new Promise((resolve) => setTimeout(resolve, waitTime + 100));
443
+ soonest.status = "active";
444
+ soonest.rateLimitedUntil = 0;
445
+ await this.saveAccounts();
446
+ log.info(`[ACCOUNTS] [RESTORE] Account ${soonest.username} restored from rate limit wait`);
447
+ return soonest;
448
+ }
449
+ }
450
+ return null;
451
+ }
452
+ return account;
453
+ }
454
+ selectAccountByQuota(pool) {
455
+ const candidates = pool.filter((a) => a.premiumRemaining === undefined || a.premiumRemaining > 0);
456
+ if (candidates.length === 0)
457
+ return null;
458
+ const validCandidates = candidates.filter((a) => a.premiumRemaining !== undefined);
459
+ if (validCandidates.length === 0) {
460
+ candidates.sort((a, b) => (a.lastUsed || 0) - (b.lastUsed || 0));
461
+ return candidates[0];
462
+ }
463
+ const totalWeight = validCandidates.reduce((sum, a) => sum + (a.premiumRemaining || 0), 0);
464
+ if (totalWeight <= 0) {
465
+ validCandidates.sort((a, b) => (a.lastUsed || 0) - (b.lastUsed || 0));
466
+ return validCandidates[0];
467
+ }
468
+ let random = Math.random() * totalWeight;
469
+ for (const account of validCandidates) {
470
+ random -= account.premiumRemaining || 0;
471
+ if (random <= 0) {
472
+ return account;
473
+ }
474
+ }
475
+ return validCandidates[validCandidates.length - 1];
476
+ }
477
+ selectNextAccount(sessionId, model) {
478
+ const now = Date.now();
479
+ this.accounts.forEach((a) => {
480
+ if (a.status === "rate_limited" &&
481
+ a.rateLimitedUntil &&
482
+ a.rateLimitedUntil <= now) {
483
+ log.info(`[ACCOUNTS] [RESTORE] Rate limit expired for ${a.username}, restoring to active`);
484
+ a.status = "active";
485
+ a.rateLimitedUntil = 0;
486
+ }
487
+ });
488
+ let availableAccounts = this.accounts;
489
+ if (model) {
490
+ const compatible = availableAccounts.filter(acc => isModelAvailableForSku(model, acc.sku));
491
+ if (compatible.length === 0) {
492
+ const requiredTier = getRequiredTierForModel(model);
493
+ log.error(`[ACCOUNTS] No accounts support model "${model}" (requires ${requiredTier})`);
494
+ throw new Error(`No accounts available for model "${model}". Requires ${requiredTier}.`);
495
+ }
496
+ if (compatible.length < availableAccounts.length) {
497
+ log.info(`[ACCOUNTS] Filtered ${availableAccounts.length - compatible.length} accounts incompatible with "${model}"`);
498
+ }
499
+ availableAccounts = compatible;
500
+ }
501
+ const validAccounts = [];
502
+ const healthyAccounts = [];
503
+ for (const a of availableAccounts) {
504
+ if (a.enabled === false) {
505
+ continue;
506
+ }
507
+ if (a.status === "suspended") {
508
+ continue;
509
+ }
510
+ if (a.status === "quota_exhausted") {
511
+ continue;
512
+ }
513
+ if (a.status === "rate_limited" &&
514
+ (!a.rateLimitedUntil || a.rateLimitedUntil > now)) {
515
+ continue;
516
+ }
517
+ validAccounts.push(a);
518
+ if (a.health_score > MIN_HEALTH_THRESHOLD) {
519
+ healthyAccounts.push(a);
520
+ }
521
+ }
522
+ if (validAccounts.length === 0) {
523
+ const exhaustedAccounts = availableAccounts.filter((a) => a.status === "quota_exhausted");
524
+ if (exhaustedAccounts.length > 0) {
525
+ const resetDates = exhaustedAccounts
526
+ .map((a) => a.quotaResetDate)
527
+ .filter(Boolean)
528
+ .sort();
529
+ const earliestReset = resetDates[0] || "unknown";
530
+ throw new Error(`All available accounts${model ? ` for model ${model}` : ''} are out of quota. Earliest reset: ${earliestReset}`);
531
+ }
532
+ }
533
+ const pool = healthyAccounts.length > 0 ? healthyAccounts : validAccounts;
534
+ if (this.config.rotation_strategy === "sticky" && sessionId) {
535
+ const stickyData = this.sessionIdToUsername.get(sessionId);
536
+ if (stickyData) {
537
+ const account = validAccounts.find((a) => a.username === stickyData.username);
538
+ if (account) {
539
+ log.info(`[ACCOUNTS] [SELECT] Strategy: sticky. Selected ${account.username} for session ${sessionId}`);
540
+ account.lastUsed = Date.now();
541
+ return account;
542
+ }
543
+ }
544
+ }
545
+ let selected;
546
+ if (this.config.rotation_strategy === "least-used") {
547
+ const quotaAccount = this.selectAccountByQuota(pool);
548
+ if (quotaAccount) {
549
+ selected = quotaAccount;
550
+ log.info(`[ACCOUNTS] [SELECT] Strategy: least-used (quota-weighted). Selected ${selected.username} (Premium: ${selected.premiumRemaining}/${selected.premiumTotal})`);
551
+ }
552
+ }
553
+ if (!selected) {
554
+ if (this.config.rotation_strategy === 'random') {
555
+ selected = pool[Math.floor(Math.random() * pool.length)];
556
+ }
557
+ else {
558
+ pool.sort((a, b) => {
559
+ const diff = (a.lastUsed || 0) - (b.lastUsed || 0);
560
+ if (diff !== 0)
561
+ return diff;
562
+ return a.addedAt - b.addedAt;
563
+ });
564
+ selected = pool[0];
565
+ }
566
+ }
567
+ log.info("--- Account Selection ---");
568
+ log.info(`[ACCOUNTS] Strategy: ${this.config.rotation_strategy}, Pool: ${pool.length}/${this.accounts.length}`);
569
+ if (selected) {
570
+ log.info(`[ACCOUNTS] [SELECT] Selected: ${selected.username} (SKU: ${selected.sku || 'unknown'}) | Premium: ${selected.premiumRemaining}/${selected.premiumTotal}`);
571
+ }
572
+ else {
573
+ log.warn(`[ACCOUNTS] [SELECT] No healthy account available!`);
574
+ }
575
+ if (sessionId && selected) {
576
+ if (this.sessionIdToUsername.size >= 10000) {
577
+ const firstKey = this.sessionIdToUsername.keys().next().value;
578
+ if (firstKey)
579
+ this.sessionIdToUsername.delete(firstKey);
580
+ }
581
+ this.sessionIdToUsername.set(sessionId, {
582
+ username: selected.username,
583
+ addedAt: Date.now(),
584
+ });
585
+ }
586
+ if (selected) {
587
+ selected.lastUsed = Date.now();
588
+ }
589
+ return selected;
590
+ }
591
+ markQuotaExhausted(account) {
592
+ const acc = this.accounts.find((a) => a.username === account.username);
593
+ if (!acc)
594
+ return;
595
+ acc.premiumRemaining = 0;
596
+ acc.status = "quota_exhausted";
597
+ log.info(`[ACCOUNTS] [QUOTA] Marked ${acc.username} as quota exhausted`);
598
+ this.requestSave();
599
+ }
600
+ async markRateLimited(account, cooldownMs) {
601
+ const acc = this.accounts.find((a) => a.username === account.username);
602
+ if (!acc)
603
+ return;
604
+ acc.status = "rate_limited";
605
+ acc.last_error_at = Date.now();
606
+ acc.failureCount = (acc.failureCount || 0) + 1;
607
+ acc.health_score = Math.max(0, acc.health_score - 10 * 5);
608
+ acc.rateLimitedUntil = Date.now() + cooldownMs;
609
+ log.warn(`[ACCOUNTS] [RATELIMIT] Rate limit detected for ${account.username}, suspending until ${new Date(acc.rateLimitedUntil).toISOString()}`);
610
+ this.syncQuotaForAccount(acc).catch((err) => log.warn(`[ACCOUNTS] [QUOTA] Failed to sync quota during rate limit for ${account.username}`, { error: err }));
611
+ await this.saveAccounts();
612
+ }
613
+ markSuccess(account, _duration) {
614
+ const acc = this.accounts.find((a) => a.username === account.username);
615
+ if (!acc)
616
+ return;
617
+ if (acc.status === "rate_limited") {
618
+ log.info(`[ACCOUNTS] [RECOVER] Account ${account.username} recovered from rate limit`);
619
+ acc.status = "active";
620
+ acc.rateLimitedUntil = 0;
621
+ }
622
+ acc.health_score = Math.min(MAX_HEALTH, acc.health_score + 2);
623
+ acc.failureCount = 0;
624
+ acc.lastUsed = Date.now();
625
+ this.requestCount++;
626
+ if (this.requestCount >= SYNC_EVERY_N_REQUESTS) {
627
+ this.requestCount = 0;
628
+ this.syncAllQuotas().catch((err) => log.warn("[ACCOUNTS] [QUOTA] Failed to run request-based quota sync", { error: err }));
629
+ }
630
+ this.saveAccounts().catch((err) => log.error("[ACCOUNTS] [SAVE] Failed to save accounts", err));
631
+ }
632
+ markFailed(account, error, statusCode) {
633
+ const acc = this.accounts.find((a) => a.username === account.username);
634
+ if (!acc)
635
+ return;
636
+ acc.last_error_at = Date.now();
637
+ if (statusCode === 401 || statusCode === 403) {
638
+ acc.status = "suspended";
639
+ acc.health_score = 0;
640
+ acc.enabled = false;
641
+ log.error(`[ACCOUNTS] [SUSPEND] Account ${acc.username} suspended due to auth error (${statusCode})`);
642
+ }
643
+ else if (statusCode === 429) {
644
+ acc.status = "rate_limited";
645
+ acc.failureCount = (acc.failureCount || 0) + 1;
646
+ acc.health_score = Math.max(0, acc.health_score - 50);
647
+ acc.rateLimitedUntil = Date.now() + DEFAULT_RATE_LIMIT_COOLDOWN;
648
+ log.warn(`[ACCOUNTS] [RATELIMIT] Account ${acc.username} marked rate limited from status code 429`);
649
+ this.syncQuotaForAccount(acc).catch((err) => log.warn(`[ACCOUNTS] [QUOTA] Failed to sync quota during rate limit for ${acc.username}`, { error: err }));
650
+ }
651
+ else {
652
+ const isClientError = statusCode && statusCode >= 400 && statusCode < 500;
653
+ if (!isClientError) {
654
+ acc.failureCount = (acc.failureCount || 0) + 1;
655
+ }
656
+ const oldScore = acc.health_score;
657
+ acc.health_score = Math.max(0, acc.health_score - 10);
658
+ const errorMessage = error ? error.message : `HTTP ${statusCode}`;
659
+ log.warn(`[ACCOUNTS] [FAIL] Operation failed for ${acc.username}. Error: ${errorMessage}. Health: ${oldScore} -> ${acc.health_score}. Failures: ${acc.failureCount}`);
660
+ if ((acc.failureCount || 0) >= MAX_FAILURES_BEFORE_SUSPEND) {
661
+ log.warn(`[ACCOUNTS] [SUSPEND] Account ${acc.username} suspended due to excessive failures (${acc.failureCount})`);
662
+ acc.status = "suspended";
663
+ acc.enabled = false;
664
+ }
665
+ }
666
+ this.saveAccounts().catch((err) => log.error("[ACCOUNTS] [SAVE] Failed to save accounts", err));
667
+ }
668
+ listAccounts() {
669
+ return [...this.accounts];
670
+ }
671
+ async updateAccount(username, updates) {
672
+ const account = this.accounts.find((a) => a.username === username);
673
+ if (account) {
674
+ Object.assign(account, updates);
675
+ await this.saveAccounts();
676
+ }
677
+ }
678
+ async toggleAccount(username, enabled) {
679
+ const account = this.accounts.find((a) => a.username === username);
680
+ if (account) {
681
+ account.enabled = enabled;
682
+ await this.saveAccounts();
683
+ }
684
+ }
685
+ updateAccountQuota(username, quota) {
686
+ const account = this.accounts.find((a) => a.username === username);
687
+ if (!account) {
688
+ log.warn(`[ACCOUNTS] [QUOTA] Cannot update quota - account not found: ${username}`);
689
+ return;
690
+ }
691
+ account.premiumRemaining = quota.premium.remaining;
692
+ account.premiumTotal = quota.premium.total;
693
+ account.quotaResetDate = quota.resetDate || undefined;
694
+ account.lastQuotaSync = Date.now();
695
+ log.info(`[ACCOUNTS] [QUOTA] Updated quota for ${username}: Premium=${account.premiumRemaining}/${account.premiumTotal}`);
696
+ this.requestSave();
697
+ }
698
+ async removeAccount(username) {
699
+ this.accounts = this.accounts.filter((a) => a.username !== username);
700
+ await this.saveAccounts([username]);
701
+ }
702
+ }
703
+ //# sourceMappingURL=accounts.js.map