commons-proxy 2.0.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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +757 -0
  3. package/bin/cli.js +146 -0
  4. package/package.json +97 -0
  5. package/public/Complaint Details.pdf +0 -0
  6. package/public/Cyber Crime Portal.pdf +0 -0
  7. package/public/app.js +229 -0
  8. package/public/css/src/input.css +523 -0
  9. package/public/css/style.css +1 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +549 -0
  12. package/public/js/components/account-manager.js +356 -0
  13. package/public/js/components/add-account-modal.js +414 -0
  14. package/public/js/components/claude-config.js +420 -0
  15. package/public/js/components/dashboard/charts.js +605 -0
  16. package/public/js/components/dashboard/filters.js +362 -0
  17. package/public/js/components/dashboard/stats.js +110 -0
  18. package/public/js/components/dashboard.js +236 -0
  19. package/public/js/components/logs-viewer.js +100 -0
  20. package/public/js/components/models.js +36 -0
  21. package/public/js/components/server-config.js +349 -0
  22. package/public/js/config/constants.js +102 -0
  23. package/public/js/data-store.js +375 -0
  24. package/public/js/settings-store.js +58 -0
  25. package/public/js/store.js +99 -0
  26. package/public/js/translations/en.js +367 -0
  27. package/public/js/translations/id.js +412 -0
  28. package/public/js/translations/pt.js +308 -0
  29. package/public/js/translations/tr.js +358 -0
  30. package/public/js/translations/zh.js +373 -0
  31. package/public/js/utils/account-actions.js +189 -0
  32. package/public/js/utils/error-handler.js +96 -0
  33. package/public/js/utils/model-config.js +42 -0
  34. package/public/js/utils/ui-logger.js +143 -0
  35. package/public/js/utils/validators.js +77 -0
  36. package/public/js/utils.js +69 -0
  37. package/public/proxy-server-64.png +0 -0
  38. package/public/views/accounts.html +361 -0
  39. package/public/views/dashboard.html +484 -0
  40. package/public/views/logs.html +97 -0
  41. package/public/views/models.html +331 -0
  42. package/public/views/settings.html +1327 -0
  43. package/src/account-manager/credentials.js +378 -0
  44. package/src/account-manager/index.js +462 -0
  45. package/src/account-manager/onboarding.js +112 -0
  46. package/src/account-manager/rate-limits.js +369 -0
  47. package/src/account-manager/storage.js +160 -0
  48. package/src/account-manager/strategies/base-strategy.js +109 -0
  49. package/src/account-manager/strategies/hybrid-strategy.js +339 -0
  50. package/src/account-manager/strategies/index.js +79 -0
  51. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  52. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  53. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  54. package/src/account-manager/strategies/trackers/index.js +9 -0
  55. package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
  56. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
  57. package/src/auth/database.js +169 -0
  58. package/src/auth/oauth.js +548 -0
  59. package/src/auth/token-extractor.js +117 -0
  60. package/src/cli/accounts.js +648 -0
  61. package/src/cloudcode/index.js +29 -0
  62. package/src/cloudcode/message-handler.js +510 -0
  63. package/src/cloudcode/model-api.js +248 -0
  64. package/src/cloudcode/rate-limit-parser.js +235 -0
  65. package/src/cloudcode/request-builder.js +93 -0
  66. package/src/cloudcode/session-manager.js +47 -0
  67. package/src/cloudcode/sse-parser.js +121 -0
  68. package/src/cloudcode/sse-streamer.js +293 -0
  69. package/src/cloudcode/streaming-handler.js +615 -0
  70. package/src/config.js +125 -0
  71. package/src/constants.js +407 -0
  72. package/src/errors.js +242 -0
  73. package/src/fallback-config.js +29 -0
  74. package/src/format/content-converter.js +193 -0
  75. package/src/format/index.js +20 -0
  76. package/src/format/request-converter.js +255 -0
  77. package/src/format/response-converter.js +120 -0
  78. package/src/format/schema-sanitizer.js +673 -0
  79. package/src/format/signature-cache.js +88 -0
  80. package/src/format/thinking-utils.js +648 -0
  81. package/src/index.js +148 -0
  82. package/src/modules/usage-stats.js +205 -0
  83. package/src/providers/anthropic-provider.js +258 -0
  84. package/src/providers/base-provider.js +157 -0
  85. package/src/providers/cloudcode.js +94 -0
  86. package/src/providers/copilot.js +399 -0
  87. package/src/providers/github-provider.js +287 -0
  88. package/src/providers/google-provider.js +192 -0
  89. package/src/providers/index.js +211 -0
  90. package/src/providers/openai-compatible.js +265 -0
  91. package/src/providers/openai-provider.js +271 -0
  92. package/src/providers/openrouter-provider.js +325 -0
  93. package/src/providers/setup.js +83 -0
  94. package/src/server.js +870 -0
  95. package/src/utils/claude-config.js +245 -0
  96. package/src/utils/helpers.js +51 -0
  97. package/src/utils/logger.js +142 -0
  98. package/src/utils/native-module-helper.js +162 -0
  99. package/src/webui/index.js +1134 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Rate Limit Management
3
+ *
4
+ * Handles rate limit tracking and state management for accounts.
5
+ * All rate limits are model-specific.
6
+ */
7
+
8
+ import { DEFAULT_COOLDOWN_MS } from '../constants.js';
9
+ import { formatDuration } from '../utils/helpers.js';
10
+ import { logger } from '../utils/logger.js';
11
+
12
+ /**
13
+ * Check if all accounts are rate-limited for a specific model
14
+ *
15
+ * @param {Array} accounts - Array of account objects
16
+ * @param {string} modelId - Model ID to check rate limits for
17
+ * @returns {boolean} True if all accounts are rate-limited
18
+ */
19
+ export function isAllRateLimited(accounts, modelId) {
20
+ if (accounts.length === 0) return true;
21
+ if (!modelId) return false; // No model specified = not rate limited
22
+
23
+ return accounts.every(acc => {
24
+ if (acc.isInvalid) return true; // Invalid accounts count as unavailable
25
+ if (acc.enabled === false) return true; // Disabled accounts count as unavailable
26
+ const modelLimits = acc.modelRateLimits || {};
27
+ const limit = modelLimits[modelId];
28
+ return limit && limit.isRateLimited && limit.resetTime > Date.now();
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Get list of available (non-rate-limited, non-invalid) accounts for a model
34
+ *
35
+ * @param {Array} accounts - Array of account objects
36
+ * @param {string} [modelId] - Model ID to filter by
37
+ * @returns {Array} Array of available account objects
38
+ */
39
+ export function getAvailableAccounts(accounts, modelId = null) {
40
+ return accounts.filter(acc => {
41
+ if (acc.isInvalid) return false;
42
+
43
+ // WebUI: Skip disabled accounts
44
+ if (acc.enabled === false) return false;
45
+
46
+ if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
47
+ const limit = acc.modelRateLimits[modelId];
48
+ if (limit.isRateLimited && limit.resetTime > Date.now()) {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ return true;
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Get list of invalid accounts
59
+ *
60
+ * @param {Array} accounts - Array of account objects
61
+ * @returns {Array} Array of invalid account objects
62
+ */
63
+ export function getInvalidAccounts(accounts) {
64
+ return accounts.filter(acc => acc.isInvalid);
65
+ }
66
+
67
+ /**
68
+ * Clear expired rate limits
69
+ *
70
+ * @param {Array} accounts - Array of account objects
71
+ * @returns {number} Number of rate limits cleared
72
+ */
73
+ export function clearExpiredLimits(accounts) {
74
+ const now = Date.now();
75
+ let cleared = 0;
76
+
77
+ for (const account of accounts) {
78
+ if (account.modelRateLimits) {
79
+ for (const [modelId, limit] of Object.entries(account.modelRateLimits)) {
80
+ if (limit.isRateLimited && limit.resetTime <= now) {
81
+ limit.isRateLimited = false;
82
+ limit.resetTime = null;
83
+ cleared++;
84
+ logger.success(`[AccountManager] Rate limit expired for: ${account.email} (model: ${modelId})`);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ return cleared;
91
+ }
92
+
93
+ /**
94
+ * Clear all rate limits to force a fresh check (optimistic retry strategy)
95
+ *
96
+ * @param {Array} accounts - Array of account objects
97
+ */
98
+ export function resetAllRateLimits(accounts) {
99
+ for (const account of accounts) {
100
+ if (account.modelRateLimits) {
101
+ for (const key of Object.keys(account.modelRateLimits)) {
102
+ account.modelRateLimits[key] = { isRateLimited: false, resetTime: null };
103
+ }
104
+ }
105
+ }
106
+ logger.warn('[AccountManager] Reset all rate limits for optimistic retry');
107
+ }
108
+
109
+ /**
110
+ * Mark an account as rate-limited for a specific model
111
+ *
112
+ * @param {Array} accounts - Array of account objects
113
+ * @param {string} email - Email of the account to mark
114
+ * @param {number|null} resetMs - Time in ms until rate limit resets (from API)
115
+ * @param {string} modelId - Model ID to mark rate limit for
116
+ * @returns {boolean} True if account was found and marked
117
+ */
118
+ export function markRateLimited(accounts, email, resetMs = null, modelId) {
119
+ const account = accounts.find(a => a.email === email);
120
+ if (!account) return false;
121
+
122
+ // Store the ACTUAL reset time from the API
123
+ // This is used to decide whether to wait (short) or switch accounts (long)
124
+ const actualResetMs = (resetMs && resetMs > 0) ? resetMs : DEFAULT_COOLDOWN_MS;
125
+
126
+ if (!account.modelRateLimits) {
127
+ account.modelRateLimits = {};
128
+ }
129
+
130
+ account.modelRateLimits[modelId] = {
131
+ isRateLimited: true,
132
+ resetTime: Date.now() + actualResetMs, // Actual reset time for decisions
133
+ actualResetMs: actualResetMs // Original duration from API
134
+ };
135
+
136
+ // Track consecutive failures for progressive backoff (matches opencode-cloudcode-auth)
137
+ account.consecutiveFailures = (account.consecutiveFailures || 0) + 1;
138
+
139
+ // Log appropriately based on duration
140
+ if (actualResetMs > DEFAULT_COOLDOWN_MS) {
141
+ logger.warn(
142
+ `[AccountManager] Quota exhausted: ${email} (model: ${modelId}). Resets in ${formatDuration(actualResetMs)}`
143
+ );
144
+ } else {
145
+ logger.warn(
146
+ `[AccountManager] Rate limited: ${email} (model: ${modelId}). Available in ${formatDuration(actualResetMs)}`
147
+ );
148
+ }
149
+
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Mark an account as invalid (credentials need re-authentication)
155
+ *
156
+ * @param {Array} accounts - Array of account objects
157
+ * @param {string} email - Email of the account to mark
158
+ * @param {string} reason - Reason for marking as invalid
159
+ * @returns {boolean} True if account was found and marked
160
+ */
161
+ export function markInvalid(accounts, email, reason = 'Unknown error') {
162
+ const account = accounts.find(a => a.email === email);
163
+ if (!account) return false;
164
+
165
+ account.isInvalid = true;
166
+ account.invalidReason = reason;
167
+ account.invalidAt = Date.now();
168
+
169
+ logger.error(
170
+ `[AccountManager] ⚠ Account INVALID: ${email}`
171
+ );
172
+ logger.error(
173
+ `[AccountManager] Reason: ${reason}`
174
+ );
175
+ logger.error(
176
+ `[AccountManager] Run 'npm run accounts' to re-authenticate this account`
177
+ );
178
+
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * Get the minimum wait time until any account becomes available for a model
184
+ *
185
+ * @param {Array} accounts - Array of account objects
186
+ * @param {string} modelId - Model ID to check
187
+ * @returns {number} Wait time in milliseconds
188
+ */
189
+ export function getMinWaitTimeMs(accounts, modelId) {
190
+ if (!isAllRateLimited(accounts, modelId)) return 0;
191
+
192
+ const now = Date.now();
193
+ let minWait = Infinity;
194
+ let soonestAccount = null;
195
+
196
+ for (const account of accounts) {
197
+ if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
198
+ const limit = account.modelRateLimits[modelId];
199
+ if (limit.isRateLimited && limit.resetTime) {
200
+ const wait = limit.resetTime - now;
201
+ if (wait > 0 && wait < minWait) {
202
+ minWait = wait;
203
+ soonestAccount = account;
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ if (soonestAccount) {
210
+ logger.info(`[AccountManager] Shortest wait: ${formatDuration(minWait)} (account: ${soonestAccount.email})`);
211
+ }
212
+
213
+ return minWait === Infinity ? DEFAULT_COOLDOWN_MS : minWait;
214
+ }
215
+
216
+ /**
217
+ * Get the rate limit info for a specific account and model
218
+ * Returns the actual reset time from API, not capped
219
+ *
220
+ * @param {Array} accounts - Array of account objects
221
+ * @param {string} email - Email of the account
222
+ * @param {string} modelId - Model ID to check
223
+ * @returns {{isRateLimited: boolean, actualResetMs: number|null, waitMs: number}} Rate limit info
224
+ */
225
+ export function getRateLimitInfo(accounts, email, modelId) {
226
+ const account = accounts.find(a => a.email === email);
227
+ if (!account || !account.modelRateLimits || !account.modelRateLimits[modelId]) {
228
+ return { isRateLimited: false, actualResetMs: null, waitMs: 0 };
229
+ }
230
+
231
+ const limit = account.modelRateLimits[modelId];
232
+ const now = Date.now();
233
+ const waitMs = limit.resetTime ? Math.max(0, limit.resetTime - now) : 0;
234
+
235
+ return {
236
+ isRateLimited: limit.isRateLimited && waitMs > 0,
237
+ actualResetMs: limit.actualResetMs || null,
238
+ waitMs
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Get the consecutive failure count for an account
244
+ * Used for progressive backoff calculation (matches opencode-cloudcode-auth)
245
+ *
246
+ * @param {Array} accounts - Array of account objects
247
+ * @param {string} email - Email of the account
248
+ * @returns {number} Number of consecutive failures
249
+ */
250
+ export function getConsecutiveFailures(accounts, email) {
251
+ const account = accounts.find(a => a.email === email);
252
+ return account?.consecutiveFailures || 0;
253
+ }
254
+
255
+ /**
256
+ * Reset the consecutive failure count for an account
257
+ * Called on successful request (matches opencode-cloudcode-auth)
258
+ *
259
+ * @param {Array} accounts - Array of account objects
260
+ * @param {string} email - Email of the account
261
+ * @returns {boolean} True if account was found and reset
262
+ */
263
+ export function resetConsecutiveFailures(accounts, email) {
264
+ const account = accounts.find(a => a.email === email);
265
+ if (!account) return false;
266
+ account.consecutiveFailures = 0;
267
+ return true;
268
+ }
269
+
270
+ /**
271
+ * Increment the consecutive failure count for an account WITHOUT marking as rate limited
272
+ * Used for quick retries where we want to track failures but not skip the account
273
+ * (matches opencode-cloudcode-auth behavior of always incrementing on 429)
274
+ *
275
+ * @param {Array} accounts - Array of account objects
276
+ * @param {string} email - Email of the account
277
+ * @returns {number} New consecutive failure count
278
+ */
279
+ export function incrementConsecutiveFailures(accounts, email) {
280
+ const account = accounts.find(a => a.email === email);
281
+ if (!account) return 0;
282
+ account.consecutiveFailures = (account.consecutiveFailures || 0) + 1;
283
+ return account.consecutiveFailures;
284
+ }
285
+
286
+ // ============================================================================
287
+ // Cooldown Mechanism (matches opencode-cloudcode-auth)
288
+ // Separate from rate limits - used for temporary backoff after failures
289
+ // ============================================================================
290
+
291
+ /**
292
+ * Cooldown reasons for debugging/logging
293
+ */
294
+ export const CooldownReason = {
295
+ RATE_LIMIT: 'rate_limit',
296
+ AUTH_FAILURE: 'auth_failure',
297
+ CONSECUTIVE_FAILURES: 'consecutive_failures',
298
+ SERVER_ERROR: 'server_error'
299
+ };
300
+
301
+ /**
302
+ * Mark an account as cooling down for a specified duration
303
+ * Used for temporary backoff separate from rate limits
304
+ *
305
+ * @param {Array} accounts - Array of account objects
306
+ * @param {string} email - Email of the account
307
+ * @param {number} cooldownMs - Duration of cooldown in milliseconds
308
+ * @param {string} [reason] - Reason for the cooldown
309
+ * @returns {boolean} True if account was found and marked
310
+ */
311
+ export function markAccountCoolingDown(accounts, email, cooldownMs, reason = CooldownReason.RATE_LIMIT) {
312
+ const account = accounts.find(a => a.email === email);
313
+ if (!account) return false;
314
+
315
+ account.coolingDownUntil = Date.now() + cooldownMs;
316
+ account.cooldownReason = reason;
317
+
318
+ logger.debug(`[AccountManager] Account ${email} cooling down for ${formatDuration(cooldownMs)} (reason: ${reason})`);
319
+ return true;
320
+ }
321
+
322
+ /**
323
+ * Check if an account is currently cooling down
324
+ * Automatically clears expired cooldowns
325
+ *
326
+ * @param {Object} account - Account object
327
+ * @returns {boolean} True if account is cooling down
328
+ */
329
+ export function isAccountCoolingDown(account) {
330
+ if (!account || account.coolingDownUntil === undefined) {
331
+ return false;
332
+ }
333
+
334
+ const now = Date.now();
335
+ if (now >= account.coolingDownUntil) {
336
+ // Cooldown expired - clear it
337
+ clearAccountCooldown(account);
338
+ return false;
339
+ }
340
+
341
+ return true;
342
+ }
343
+
344
+ /**
345
+ * Clear the cooldown for an account
346
+ *
347
+ * @param {Object} account - Account object
348
+ */
349
+ export function clearAccountCooldown(account) {
350
+ if (account) {
351
+ delete account.coolingDownUntil;
352
+ delete account.cooldownReason;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Get time remaining until cooldown expires for an account
358
+ *
359
+ * @param {Object} account - Account object
360
+ * @returns {number} Milliseconds until cooldown expires, 0 if not cooling down
361
+ */
362
+ export function getCooldownRemaining(account) {
363
+ if (!account || account.coolingDownUntil === undefined) {
364
+ return 0;
365
+ }
366
+
367
+ const remaining = account.coolingDownUntil - Date.now();
368
+ return remaining > 0 ? remaining : 0;
369
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Account Storage
3
+ *
4
+ * Handles loading and saving account configuration to disk.
5
+ */
6
+
7
+ import { readFile, writeFile, mkdir, access } from 'fs/promises';
8
+ import { constants as fsConstants } from 'fs';
9
+ import { dirname } from 'path';
10
+ import { ACCOUNT_CONFIG_PATH } from '../constants.js';
11
+ import { getAuthStatus } from '../auth/database.js';
12
+ import { logger } from '../utils/logger.js';
13
+
14
+ /**
15
+ * Detect provider from legacy source field
16
+ * @param {string} source - Account source ('oauth', 'manual', 'database')
17
+ * @returns {string} Provider ID
18
+ */
19
+ function detectProviderFromSource(source) {
20
+ // Legacy accounts use 'oauth' or 'database' for Google OAuth
21
+ if (source === 'oauth' || source === 'database') {
22
+ return 'google';
23
+ }
24
+ // Manual accounts default to Google (legacy behavior)
25
+ if (source === 'manual') {
26
+ return 'google';
27
+ }
28
+ // Default to Google
29
+ return 'google';
30
+ }
31
+
32
+ /**
33
+ * Load accounts from the config file
34
+ *
35
+ * @param {string} configPath - Path to the config file
36
+ * @returns {Promise<{accounts: Array, settings: Object, activeIndex: number}>}
37
+ */
38
+ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
39
+ try {
40
+ // Check if config file exists using async access
41
+ await access(configPath, fsConstants.F_OK);
42
+ const configData = await readFile(configPath, 'utf-8');
43
+ const config = JSON.parse(configData);
44
+
45
+ const accounts = (config.accounts || []).map(acc => ({
46
+ ...acc,
47
+ lastUsed: acc.lastUsed || null,
48
+ enabled: acc.enabled !== false, // Default to true if not specified
49
+ // Reset invalid flag on startup - give accounts a fresh chance to refresh
50
+ isInvalid: false,
51
+ invalidReason: null,
52
+ modelRateLimits: acc.modelRateLimits || {},
53
+ // New fields for subscription and quota tracking
54
+ subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
55
+ quota: acc.quota || { models: {}, lastChecked: null },
56
+ // Multi-provider support
57
+ provider: acc.provider || detectProviderFromSource(acc.source),
58
+ customApiEndpoint: acc.customApiEndpoint || null
59
+ }));
60
+
61
+ const settings = config.settings || {};
62
+ let activeIndex = config.activeIndex || 0;
63
+
64
+ // Clamp activeIndex to valid range
65
+ if (activeIndex >= accounts.length) {
66
+ activeIndex = 0;
67
+ }
68
+
69
+ logger.info(`[AccountManager] Loaded ${accounts.length} account(s) from config`);
70
+
71
+ return { accounts, settings, activeIndex };
72
+ } catch (error) {
73
+ if (error.code === 'ENOENT') {
74
+ // No config file - return empty
75
+ logger.info('[AccountManager] No config file found. Using Antigravity database (single account mode)');
76
+ } else {
77
+ logger.error('[AccountManager] Failed to load config:', error.message);
78
+ }
79
+ return { accounts: [], settings: {}, activeIndex: 0 };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Load the default account from Antigravity's database
85
+ *
86
+ * @param {string} dbPath - Optional path to the database
87
+ * @returns {{accounts: Array, tokenCache: Map}}
88
+ */
89
+ export function loadDefaultAccount(dbPath) {
90
+ try {
91
+ const authData = getAuthStatus(dbPath);
92
+ if (authData?.apiKey) {
93
+ const account = {
94
+ email: authData.email || 'default@commons',
95
+ source: 'database',
96
+ lastUsed: null,
97
+ modelRateLimits: {}
98
+ };
99
+
100
+ const tokenCache = new Map();
101
+ tokenCache.set(account.email, {
102
+ token: authData.apiKey,
103
+ extractedAt: Date.now()
104
+ });
105
+
106
+ logger.info(`[AccountManager] Loaded default account: ${account.email}`);
107
+
108
+ return { accounts: [account], tokenCache };
109
+ }
110
+ } catch (error) {
111
+ logger.error('[AccountManager] Failed to load default account:', error.message);
112
+ }
113
+
114
+ return { accounts: [], tokenCache: new Map() };
115
+ }
116
+
117
+ /**
118
+ * Save account configuration to disk
119
+ *
120
+ * @param {string} configPath - Path to the config file
121
+ * @param {Array} accounts - Array of account objects
122
+ * @param {Object} settings - Settings object
123
+ * @param {number} activeIndex - Current active account index
124
+ */
125
+ export async function saveAccounts(configPath, accounts, settings, activeIndex) {
126
+ try {
127
+ // Ensure directory exists
128
+ const dir = dirname(configPath);
129
+ await mkdir(dir, { recursive: true });
130
+
131
+ const config = {
132
+ accounts: accounts.map(acc => ({
133
+ email: acc.email,
134
+ source: acc.source,
135
+ enabled: acc.enabled !== false, // Persist enabled state
136
+ dbPath: acc.dbPath || null,
137
+ refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
138
+ apiKey: (acc.source === 'manual' || acc.provider !== 'google') ? acc.apiKey : undefined,
139
+ projectId: acc.projectId || undefined,
140
+ addedAt: acc.addedAt || undefined,
141
+ isInvalid: acc.isInvalid || false,
142
+ invalidReason: acc.invalidReason || null,
143
+ modelRateLimits: acc.modelRateLimits || {},
144
+ lastUsed: acc.lastUsed,
145
+ // Persist subscription and quota data
146
+ subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
147
+ quota: acc.quota || { models: {}, lastChecked: null },
148
+ // Multi-provider support
149
+ provider: acc.provider || detectProviderFromSource(acc.source),
150
+ customApiEndpoint: acc.customApiEndpoint || undefined
151
+ })),
152
+ settings: settings,
153
+ activeIndex: activeIndex
154
+ };
155
+
156
+ await writeFile(configPath, JSON.stringify(config, null, 2));
157
+ } catch (error) {
158
+ logger.error('[AccountManager] Failed to save config:', error.message);
159
+ }
160
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Base Strategy
3
+ *
4
+ * Abstract base class defining the interface for account selection strategies.
5
+ * All strategies must implement the selectAccount method.
6
+ */
7
+
8
+ import { isAccountCoolingDown } from '../rate-limits.js';
9
+
10
+ /**
11
+ * @typedef {Object} SelectionResult
12
+ * @property {Object|null} account - The selected account or null if none available
13
+ * @property {number} index - The index of the selected account
14
+ * @property {number} [waitMs] - Optional wait time before account becomes available
15
+ */
16
+
17
+ export class BaseStrategy {
18
+ /**
19
+ * Create a new BaseStrategy
20
+ * @param {Object} config - Strategy configuration
21
+ */
22
+ constructor(config = {}) {
23
+ if (new.target === BaseStrategy) {
24
+ throw new Error('BaseStrategy is abstract and cannot be instantiated directly');
25
+ }
26
+ this.config = config;
27
+ }
28
+
29
+ /**
30
+ * Select an account for a request
31
+ * @param {Array} accounts - Array of account objects
32
+ * @param {string} modelId - The model ID for the request
33
+ * @param {Object} options - Additional options
34
+ * @param {number} options.currentIndex - Current account index
35
+ * @param {string} [options.sessionId] - Session ID for cache continuity
36
+ * @param {Function} [options.onSave] - Callback to save changes
37
+ * @returns {SelectionResult} The selected account and index
38
+ */
39
+ selectAccount(accounts, modelId, options = {}) {
40
+ throw new Error('selectAccount must be implemented by subclass');
41
+ }
42
+
43
+ /**
44
+ * Called after a successful request
45
+ * @param {Object} account - The account that was used
46
+ * @param {string} modelId - The model ID that was used
47
+ */
48
+ onSuccess(account, modelId) {
49
+ // Default: no-op, override in subclass if needed
50
+ }
51
+
52
+ /**
53
+ * Called when a request is rate-limited
54
+ * @param {Object} account - The account that was rate-limited
55
+ * @param {string} modelId - The model ID that was rate-limited
56
+ */
57
+ onRateLimit(account, modelId) {
58
+ // Default: no-op, override in subclass if needed
59
+ }
60
+
61
+ /**
62
+ * Called when a request fails (non-rate-limit error)
63
+ * @param {Object} account - The account that failed
64
+ * @param {string} modelId - The model ID that failed
65
+ */
66
+ onFailure(account, modelId) {
67
+ // Default: no-op, override in subclass if needed
68
+ }
69
+
70
+ /**
71
+ * Check if an account is usable for a specific model
72
+ * @param {Object} account - Account object
73
+ * @param {string} modelId - Model ID to check
74
+ * @returns {boolean} True if account is usable
75
+ */
76
+ isAccountUsable(account, modelId) {
77
+ if (!account || account.isInvalid) return false;
78
+
79
+ // Skip disabled accounts
80
+ if (account.enabled === false) return false;
81
+
82
+ // Check if account is cooling down (matches opencode-cloudcode-auth)
83
+ if (isAccountCoolingDown(account)) return false;
84
+
85
+ // Check model-specific rate limit
86
+ if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
87
+ const limit = account.modelRateLimits[modelId];
88
+ if (limit.isRateLimited && limit.resetTime > Date.now()) {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Get all usable accounts for a model
98
+ * @param {Array} accounts - Array of account objects
99
+ * @param {string} modelId - Model ID to check
100
+ * @returns {Array} Array of usable accounts with their original indices
101
+ */
102
+ getUsableAccounts(accounts, modelId) {
103
+ return accounts
104
+ .map((account, index) => ({ account, index }))
105
+ .filter(({ account }) => this.isAccountUsable(account, modelId));
106
+ }
107
+ }
108
+
109
+ export default BaseStrategy;