@kamel-ahmed/proxy-claude 1.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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -0
  3. package/bin/cli.js +124 -0
  4. package/package.json +80 -0
  5. package/public/app.js +228 -0
  6. package/public/css/src/input.css +523 -0
  7. package/public/css/style.css +1 -0
  8. package/public/favicon.svg +10 -0
  9. package/public/index.html +381 -0
  10. package/public/js/components/account-manager.js +245 -0
  11. package/public/js/components/claude-config.js +420 -0
  12. package/public/js/components/dashboard/charts.js +589 -0
  13. package/public/js/components/dashboard/filters.js +362 -0
  14. package/public/js/components/dashboard/stats.js +110 -0
  15. package/public/js/components/dashboard.js +236 -0
  16. package/public/js/components/logs-viewer.js +100 -0
  17. package/public/js/components/models.js +36 -0
  18. package/public/js/components/server-config.js +349 -0
  19. package/public/js/config/constants.js +102 -0
  20. package/public/js/data-store.js +386 -0
  21. package/public/js/settings-store.js +58 -0
  22. package/public/js/store.js +78 -0
  23. package/public/js/translations/en.js +351 -0
  24. package/public/js/translations/id.js +396 -0
  25. package/public/js/translations/pt.js +287 -0
  26. package/public/js/translations/tr.js +342 -0
  27. package/public/js/translations/zh.js +357 -0
  28. package/public/js/utils/account-actions.js +189 -0
  29. package/public/js/utils/error-handler.js +96 -0
  30. package/public/js/utils/model-config.js +42 -0
  31. package/public/js/utils/validators.js +77 -0
  32. package/public/js/utils.js +69 -0
  33. package/public/views/accounts.html +329 -0
  34. package/public/views/dashboard.html +484 -0
  35. package/public/views/logs.html +97 -0
  36. package/public/views/models.html +331 -0
  37. package/public/views/settings.html +1329 -0
  38. package/src/account-manager/credentials.js +243 -0
  39. package/src/account-manager/index.js +380 -0
  40. package/src/account-manager/onboarding.js +117 -0
  41. package/src/account-manager/rate-limits.js +237 -0
  42. package/src/account-manager/storage.js +136 -0
  43. package/src/account-manager/strategies/base-strategy.js +104 -0
  44. package/src/account-manager/strategies/hybrid-strategy.js +195 -0
  45. package/src/account-manager/strategies/index.js +79 -0
  46. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  47. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  48. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  49. package/src/account-manager/strategies/trackers/index.js +8 -0
  50. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +121 -0
  51. package/src/auth/database.js +169 -0
  52. package/src/auth/oauth.js +419 -0
  53. package/src/auth/token-extractor.js +117 -0
  54. package/src/cli/accounts.js +512 -0
  55. package/src/cli/refresh.js +201 -0
  56. package/src/cli/setup.js +338 -0
  57. package/src/cloudcode/index.js +29 -0
  58. package/src/cloudcode/message-handler.js +386 -0
  59. package/src/cloudcode/model-api.js +248 -0
  60. package/src/cloudcode/rate-limit-parser.js +181 -0
  61. package/src/cloudcode/request-builder.js +93 -0
  62. package/src/cloudcode/session-manager.js +47 -0
  63. package/src/cloudcode/sse-parser.js +121 -0
  64. package/src/cloudcode/sse-streamer.js +293 -0
  65. package/src/cloudcode/streaming-handler.js +492 -0
  66. package/src/config.js +107 -0
  67. package/src/constants.js +278 -0
  68. package/src/errors.js +238 -0
  69. package/src/fallback-config.js +29 -0
  70. package/src/format/content-converter.js +193 -0
  71. package/src/format/index.js +20 -0
  72. package/src/format/request-converter.js +248 -0
  73. package/src/format/response-converter.js +120 -0
  74. package/src/format/schema-sanitizer.js +673 -0
  75. package/src/format/signature-cache.js +88 -0
  76. package/src/format/thinking-utils.js +558 -0
  77. package/src/index.js +146 -0
  78. package/src/modules/usage-stats.js +205 -0
  79. package/src/server.js +861 -0
  80. package/src/utils/claude-config.js +245 -0
  81. package/src/utils/helpers.js +51 -0
  82. package/src/utils/logger.js +142 -0
  83. package/src/utils/native-module-helper.js +162 -0
  84. package/src/webui/index.js +707 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Credentials Management
3
+ *
4
+ * Handles OAuth token handling and project discovery.
5
+ */
6
+
7
+ import {
8
+ ANTIGRAVITY_DB_PATH,
9
+ TOKEN_REFRESH_INTERVAL_MS,
10
+ LOAD_CODE_ASSIST_ENDPOINTS,
11
+ LOAD_CODE_ASSIST_HEADERS,
12
+ DEFAULT_PROJECT_ID
13
+ } from '../constants.js';
14
+ import { refreshAccessToken } from '../auth/oauth.js';
15
+ import { getAuthStatus } from '../auth/database.js';
16
+ import { logger } from '../utils/logger.js';
17
+ import { isNetworkError } from '../utils/helpers.js';
18
+ import { onboardUser, getDefaultTierId } from './onboarding.js';
19
+
20
+ /**
21
+ * Get OAuth token for an account
22
+ *
23
+ * @param {Object} account - Account object with email and credentials
24
+ * @param {Map} tokenCache - Token cache map
25
+ * @param {Function} onInvalid - Callback when account is invalid (email, reason)
26
+ * @param {Function} onSave - Callback to save changes
27
+ * @returns {Promise<string>} OAuth access token
28
+ * @throws {Error} If token refresh fails
29
+ */
30
+ export async function getTokenForAccount(account, tokenCache, onInvalid, onSave) {
31
+ // Check cache first
32
+ const cached = tokenCache.get(account.email);
33
+ if (cached && (Date.now() - cached.extractedAt) < TOKEN_REFRESH_INTERVAL_MS) {
34
+ return cached.token;
35
+ }
36
+
37
+ // Get fresh token based on source
38
+ let token;
39
+
40
+ if (account.source === 'oauth' && account.refreshToken) {
41
+ // OAuth account - use refresh token to get new access token
42
+ try {
43
+ const tokens = await refreshAccessToken(account.refreshToken);
44
+ token = tokens.accessToken;
45
+ // Clear invalid flag on success
46
+ if (account.isInvalid) {
47
+ account.isInvalid = false;
48
+ account.invalidReason = null;
49
+ if (onSave) await onSave();
50
+ }
51
+ logger.success(`[AccountManager] Refreshed OAuth token for: ${account.email}`);
52
+ } catch (error) {
53
+ // Check if it's a transient network error
54
+ if (isNetworkError(error)) {
55
+ logger.warn(`[AccountManager] Failed to refresh token for ${account.email} due to network error: ${error.message}`);
56
+ // Do NOT mark as invalid, just throw so caller knows it failed
57
+ throw new Error(`AUTH_NETWORK_ERROR: ${error.message}`);
58
+ }
59
+
60
+ logger.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message);
61
+ // Mark account as invalid (credentials need re-auth)
62
+ if (onInvalid) onInvalid(account.email, error.message);
63
+ throw new Error(`AUTH_INVALID: ${account.email}: ${error.message}`);
64
+ }
65
+ } else if (account.source === 'manual' && account.apiKey) {
66
+ token = account.apiKey;
67
+ } else {
68
+ // Extract from database
69
+ const dbPath = account.dbPath || ANTIGRAVITY_DB_PATH;
70
+ const authData = getAuthStatus(dbPath);
71
+ token = authData.apiKey;
72
+ }
73
+
74
+ // Cache the token
75
+ tokenCache.set(account.email, {
76
+ token,
77
+ extractedAt: Date.now()
78
+ });
79
+
80
+ return token;
81
+ }
82
+
83
+ /**
84
+ * Get project ID for an account
85
+ *
86
+ * @param {Object} account - Account object
87
+ * @param {string} token - OAuth access token
88
+ * @param {Map} projectCache - Project cache map
89
+ * @returns {Promise<string>} Project ID
90
+ */
91
+ export async function getProjectForAccount(account, token, projectCache) {
92
+ // Check cache first
93
+ const cached = projectCache.get(account.email);
94
+ if (cached) {
95
+ return cached;
96
+ }
97
+
98
+ // OAuth or manual accounts may have projectId specified
99
+ if (account.projectId) {
100
+ projectCache.set(account.email, account.projectId);
101
+ return account.projectId;
102
+ }
103
+
104
+ // Discover project via loadCodeAssist API
105
+ const project = await discoverProject(token);
106
+ projectCache.set(account.email, project);
107
+ return project;
108
+ }
109
+
110
+ /**
111
+ * Discover project ID via Cloud Code API
112
+ *
113
+ * @param {string} token - OAuth access token
114
+ * @returns {Promise<string>} Project ID
115
+ */
116
+ export async function discoverProject(token) {
117
+ let lastError = null;
118
+ let gotSuccessfulResponse = false;
119
+ let loadCodeAssistData = null;
120
+
121
+ for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
122
+ try {
123
+ const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Authorization': `Bearer ${token}`,
127
+ 'Content-Type': 'application/json',
128
+ ...LOAD_CODE_ASSIST_HEADERS
129
+ },
130
+ body: JSON.stringify({
131
+ metadata: {
132
+ ideType: 'IDE_UNSPECIFIED',
133
+ platform: 'PLATFORM_UNSPECIFIED',
134
+ pluginType: 'GEMINI',
135
+ duetProject: DEFAULT_PROJECT_ID
136
+ }
137
+ })
138
+ });
139
+
140
+ if (!response.ok) {
141
+ const errorText = await response.text();
142
+ lastError = `${response.status} - ${errorText}`;
143
+ logger.debug(`[AccountManager] loadCodeAssist failed at ${endpoint}: ${lastError}`);
144
+ continue;
145
+ }
146
+
147
+ const data = await response.json();
148
+ gotSuccessfulResponse = true;
149
+ loadCodeAssistData = data;
150
+
151
+ logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data));
152
+
153
+ if (typeof data.cloudaicompanionProject === 'string') {
154
+ logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject}`);
155
+ return data.cloudaicompanionProject;
156
+ }
157
+ if (data.cloudaicompanionProject?.id) {
158
+ logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject.id}`);
159
+ return data.cloudaicompanionProject.id;
160
+ }
161
+
162
+ // No project found - log tier data and try to onboard the user
163
+ logger.info(`[AccountManager] No project in loadCodeAssist response, attempting onboardUser...`);
164
+ logger.debug(`[AccountManager] Tier data for onboarding: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`);
165
+ break;
166
+ } catch (error) {
167
+ lastError = error.message;
168
+ logger.debug(`[AccountManager] loadCodeAssist error at ${endpoint}:`, error.message);
169
+ }
170
+ }
171
+
172
+ // If we got a successful response but no project, try onboarding
173
+ if (gotSuccessfulResponse && loadCodeAssistData) {
174
+ // Priority: paidTier > currentTier > allowedTiers (consistent with model-api.js)
175
+ let tierId = null;
176
+ let tierSource = null;
177
+
178
+ if (loadCodeAssistData.paidTier?.id) {
179
+ tierId = loadCodeAssistData.paidTier.id;
180
+ tierSource = 'paidTier';
181
+ } else if (loadCodeAssistData.currentTier?.id) {
182
+ tierId = loadCodeAssistData.currentTier.id;
183
+ tierSource = 'currentTier';
184
+ } else {
185
+ tierId = getDefaultTierId(loadCodeAssistData.allowedTiers);
186
+ tierSource = 'allowedTiers';
187
+ }
188
+
189
+ tierId = tierId || 'free-tier';
190
+ logger.info(`[AccountManager] Onboarding user with tier: ${tierId} (source: ${tierSource})`);
191
+
192
+ // Check if this is a free tier (raw API values contain 'free')
193
+ const isFree = tierId.toLowerCase().includes('free');
194
+
195
+ // For non-free tiers, pass DEFAULT_PROJECT_ID as the GCP project
196
+ // The API requires a project for paid tier onboarding
197
+ const onboardedProject = await onboardUser(
198
+ token,
199
+ tierId,
200
+ isFree ? null : DEFAULT_PROJECT_ID
201
+ );
202
+ if (onboardedProject) {
203
+ logger.success(`[AccountManager] Successfully onboarded, project: ${onboardedProject}`);
204
+ return onboardedProject;
205
+ }
206
+
207
+ logger.warn(`[AccountManager] Onboarding failed, using default project: ${DEFAULT_PROJECT_ID}`);
208
+ }
209
+
210
+ // Only warn if all endpoints failed with errors (not just missing project)
211
+ if (!gotSuccessfulResponse) {
212
+ logger.warn(`[AccountManager] loadCodeAssist failed for all endpoints: ${lastError}`);
213
+ }
214
+ return DEFAULT_PROJECT_ID;
215
+ }
216
+
217
+ /**
218
+ * Clear project cache for an account
219
+ *
220
+ * @param {Map} projectCache - Project cache map
221
+ * @param {string|null} email - Email to clear cache for, or null to clear all
222
+ */
223
+ export function clearProjectCache(projectCache, email = null) {
224
+ if (email) {
225
+ projectCache.delete(email);
226
+ } else {
227
+ projectCache.clear();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Clear token cache for an account
233
+ *
234
+ * @param {Map} tokenCache - Token cache map
235
+ * @param {string|null} email - Email to clear cache for, or null to clear all
236
+ */
237
+ export function clearTokenCache(tokenCache, email = null) {
238
+ if (email) {
239
+ tokenCache.delete(email);
240
+ } else {
241
+ tokenCache.clear();
242
+ }
243
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Account Manager
3
+ * Manages multiple Antigravity accounts with configurable selection strategies,
4
+ * automatic failover, and smart cooldown for rate-limited accounts.
5
+ */
6
+
7
+ import { ACCOUNT_CONFIG_PATH } from '../constants.js';
8
+ import { loadAccounts, loadDefaultAccount, saveAccounts } from './storage.js';
9
+ import {
10
+ isAllRateLimited as checkAllRateLimited,
11
+ getAvailableAccounts as getAvailable,
12
+ getInvalidAccounts as getInvalid,
13
+ clearExpiredLimits as clearLimits,
14
+ resetAllRateLimits as resetLimits,
15
+ markRateLimited as markLimited,
16
+ markInvalid as markAccountInvalid,
17
+ getMinWaitTimeMs as getMinWait,
18
+ getRateLimitInfo as getLimitInfo
19
+ } from './rate-limits.js';
20
+ import {
21
+ getTokenForAccount as fetchToken,
22
+ getProjectForAccount as fetchProject,
23
+ clearProjectCache as clearProject,
24
+ clearTokenCache as clearToken
25
+ } from './credentials.js';
26
+ import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js';
27
+ import { logger } from '../utils/logger.js';
28
+ import { config } from '../config.js';
29
+
30
+ export class AccountManager {
31
+ #accounts = [];
32
+ #currentIndex = 0;
33
+ #configPath;
34
+ #settings = {};
35
+ #initialized = false;
36
+ #strategy = null;
37
+ #strategyName = DEFAULT_STRATEGY;
38
+
39
+ // Per-account caches
40
+ #tokenCache = new Map(); // email -> { token, extractedAt }
41
+ #projectCache = new Map(); // email -> projectId
42
+
43
+ constructor(configPath = ACCOUNT_CONFIG_PATH, strategyName = null) {
44
+ this.#configPath = configPath;
45
+ // Strategy name can be set at construction or later via initialize
46
+ if (strategyName) {
47
+ this.#strategyName = strategyName;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Initialize the account manager by loading config
53
+ * @param {string} [strategyOverride] - Override strategy name (from CLI flag or env var)
54
+ */
55
+ async initialize(strategyOverride = null) {
56
+ if (this.#initialized) return;
57
+
58
+ const { accounts, settings, activeIndex } = await loadAccounts(this.#configPath);
59
+
60
+ this.#accounts = accounts;
61
+ this.#settings = settings;
62
+ this.#currentIndex = activeIndex;
63
+
64
+ // If config exists but has no accounts, fall back to Antigravity database
65
+ if (this.#accounts.length === 0) {
66
+ logger.warn('[AccountManager] No accounts in config. Falling back to Antigravity database');
67
+ const { accounts: defaultAccounts, tokenCache } = loadDefaultAccount();
68
+ this.#accounts = defaultAccounts;
69
+ this.#tokenCache = tokenCache;
70
+ }
71
+
72
+ // Determine strategy: CLI override > env var > config file > default
73
+ const configStrategy = config?.accountSelection?.strategy;
74
+ const envStrategy = process.env.ACCOUNT_STRATEGY;
75
+ this.#strategyName = strategyOverride || envStrategy || configStrategy || this.#strategyName;
76
+
77
+ // Create the strategy instance
78
+ const strategyConfig = config?.accountSelection || {};
79
+ this.#strategy = createStrategy(this.#strategyName, strategyConfig);
80
+ logger.info(`[AccountManager] Using ${getStrategyLabel(this.#strategyName)} selection strategy`);
81
+
82
+ // Clear any expired rate limits
83
+ this.clearExpiredLimits();
84
+
85
+ this.#initialized = true;
86
+ }
87
+
88
+ /**
89
+ * Reload accounts from disk (force re-initialization)
90
+ * Useful when accounts.json is modified externally (e.g., by WebUI)
91
+ */
92
+ async reload() {
93
+ this.#initialized = false;
94
+ await this.initialize();
95
+ logger.info('[AccountManager] Accounts reloaded from disk');
96
+ }
97
+
98
+ /**
99
+ * Get the number of accounts
100
+ * @returns {number} Number of configured accounts
101
+ */
102
+ getAccountCount() {
103
+ return this.#accounts.length;
104
+ }
105
+
106
+ /**
107
+ * Check if all accounts are rate-limited
108
+ * @param {string} [modelId] - Optional model ID
109
+ * @returns {boolean} True if all accounts are rate-limited
110
+ */
111
+ isAllRateLimited(modelId = null) {
112
+ return checkAllRateLimited(this.#accounts, modelId);
113
+ }
114
+
115
+ /**
116
+ * Get list of available (non-rate-limited, non-invalid) accounts
117
+ * @param {string} [modelId] - Optional model ID
118
+ * @returns {Array<Object>} Array of available account objects
119
+ */
120
+ getAvailableAccounts(modelId = null) {
121
+ return getAvailable(this.#accounts, modelId);
122
+ }
123
+
124
+ /**
125
+ * Get list of invalid accounts
126
+ * @returns {Array<Object>} Array of invalid account objects
127
+ */
128
+ getInvalidAccounts() {
129
+ return getInvalid(this.#accounts);
130
+ }
131
+
132
+ /**
133
+ * Clear expired rate limits
134
+ * @returns {number} Number of rate limits cleared
135
+ */
136
+ clearExpiredLimits() {
137
+ const cleared = clearLimits(this.#accounts);
138
+ if (cleared > 0) {
139
+ this.saveToDisk();
140
+ }
141
+ return cleared;
142
+ }
143
+
144
+ /**
145
+ * Clear all rate limits to force a fresh check
146
+ * (Optimistic retry strategy)
147
+ * @returns {void}
148
+ */
149
+ resetAllRateLimits() {
150
+ resetLimits(this.#accounts);
151
+ }
152
+
153
+ /**
154
+ * Select an account using the configured strategy.
155
+ * This is the main method to use for account selection.
156
+ * @param {string} [modelId] - Model ID for the request
157
+ * @param {Object} [options] - Additional options
158
+ * @param {string} [options.sessionId] - Session ID for cache continuity
159
+ * @returns {{account: Object|null, waitMs: number}} Account to use and optional wait time
160
+ */
161
+ selectAccount(modelId = null, options = {}) {
162
+ if (!this.#strategy) {
163
+ throw new Error('AccountManager not initialized. Call initialize() first.');
164
+ }
165
+
166
+ const result = this.#strategy.selectAccount(this.#accounts, modelId, {
167
+ currentIndex: this.#currentIndex,
168
+ onSave: () => this.saveToDisk(),
169
+ ...options
170
+ });
171
+
172
+ this.#currentIndex = result.index;
173
+ return { account: result.account, waitMs: result.waitMs || 0 };
174
+ }
175
+
176
+ /**
177
+ * Notify the strategy of a successful request
178
+ * @param {Object} account - The account that was used
179
+ * @param {string} modelId - The model ID that was used
180
+ */
181
+ notifySuccess(account, modelId) {
182
+ if (this.#strategy) {
183
+ this.#strategy.onSuccess(account, modelId);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Notify the strategy of a rate limit
189
+ * @param {Object} account - The account that was rate-limited
190
+ * @param {string} modelId - The model ID that was rate-limited
191
+ */
192
+ notifyRateLimit(account, modelId) {
193
+ if (this.#strategy) {
194
+ this.#strategy.onRateLimit(account, modelId);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Notify the strategy of a failure
200
+ * @param {Object} account - The account that failed
201
+ * @param {string} modelId - The model ID that failed
202
+ */
203
+ notifyFailure(account, modelId) {
204
+ if (this.#strategy) {
205
+ this.#strategy.onFailure(account, modelId);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get the current strategy name
211
+ * @returns {string} Strategy name
212
+ */
213
+ getStrategyName() {
214
+ return this.#strategyName;
215
+ }
216
+
217
+ /**
218
+ * Get the strategy display label
219
+ * @returns {string} Strategy display label
220
+ */
221
+ getStrategyLabel() {
222
+ return getStrategyLabel(this.#strategyName);
223
+ }
224
+
225
+ /**
226
+ * Get the health tracker from the current strategy (if available)
227
+ * Used by handlers for consecutive failure tracking
228
+ * Only available when using hybrid strategy
229
+ * @returns {Object|null} Health tracker instance or null if not available
230
+ */
231
+ getHealthTracker() {
232
+ if (this.#strategy && typeof this.#strategy.getHealthTracker === 'function') {
233
+ return this.#strategy.getHealthTracker();
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Mark an account as rate-limited
240
+ * @param {string} email - Email of the account to mark
241
+ * @param {number|null} resetMs - Time in ms until rate limit resets (optional)
242
+ * @param {string} [modelId] - Optional model ID to mark specific limit
243
+ */
244
+ markRateLimited(email, resetMs = null, modelId = null) {
245
+ markLimited(this.#accounts, email, resetMs, modelId);
246
+ this.saveToDisk();
247
+ }
248
+
249
+ /**
250
+ * Mark an account as invalid (credentials need re-authentication)
251
+ * @param {string} email - Email of the account to mark
252
+ * @param {string} reason - Reason for marking as invalid
253
+ */
254
+ markInvalid(email, reason = 'Unknown error') {
255
+ markAccountInvalid(this.#accounts, email, reason);
256
+ this.saveToDisk();
257
+ }
258
+
259
+ /**
260
+ * Get the minimum wait time until any account becomes available
261
+ * @param {string} [modelId] - Optional model ID
262
+ * @returns {number} Wait time in milliseconds
263
+ */
264
+ getMinWaitTimeMs(modelId = null) {
265
+ return getMinWait(this.#accounts, modelId);
266
+ }
267
+
268
+ /**
269
+ * Get rate limit info for a specific account and model
270
+ * @param {string} email - Email of the account
271
+ * @param {string} modelId - Model ID to check
272
+ * @returns {{isRateLimited: boolean, actualResetMs: number|null, waitMs: number}} Rate limit info
273
+ */
274
+ getRateLimitInfo(email, modelId) {
275
+ return getLimitInfo(this.#accounts, email, modelId);
276
+ }
277
+
278
+ /**
279
+ * Get OAuth token for an account
280
+ * @param {Object} account - Account object with email and credentials
281
+ * @returns {Promise<string>} OAuth access token
282
+ * @throws {Error} If token refresh fails
283
+ */
284
+ async getTokenForAccount(account) {
285
+ return fetchToken(
286
+ account,
287
+ this.#tokenCache,
288
+ (email, reason) => this.markInvalid(email, reason),
289
+ () => this.saveToDisk()
290
+ );
291
+ }
292
+
293
+ /**
294
+ * Get project ID for an account
295
+ * @param {Object} account - Account object
296
+ * @param {string} token - OAuth access token
297
+ * @returns {Promise<string>} Project ID
298
+ */
299
+ async getProjectForAccount(account, token) {
300
+ return fetchProject(account, token, this.#projectCache);
301
+ }
302
+
303
+ /**
304
+ * Clear project cache for an account (useful on auth errors)
305
+ * @param {string|null} email - Email to clear cache for, or null to clear all
306
+ */
307
+ clearProjectCache(email = null) {
308
+ clearProject(this.#projectCache, email);
309
+ }
310
+
311
+ /**
312
+ * Clear token cache for an account (useful on auth errors)
313
+ * @param {string|null} email - Email to clear cache for, or null to clear all
314
+ */
315
+ clearTokenCache(email = null) {
316
+ clearToken(this.#tokenCache, email);
317
+ }
318
+
319
+ /**
320
+ * Save current state to disk (async)
321
+ * @returns {Promise<void>}
322
+ */
323
+ async saveToDisk() {
324
+ await saveAccounts(this.#configPath, this.#accounts, this.#settings, this.#currentIndex);
325
+ }
326
+
327
+ /**
328
+ * Get status object for logging/API
329
+ * @returns {{accounts: Array, settings: Object}} Status object with accounts and settings
330
+ */
331
+ getStatus() {
332
+ const available = this.getAvailableAccounts();
333
+ const invalid = this.getInvalidAccounts();
334
+
335
+ // Count accounts that have any active model-specific rate limits
336
+ const rateLimited = this.#accounts.filter(a => {
337
+ if (!a.modelRateLimits) return false;
338
+ return Object.values(a.modelRateLimits).some(
339
+ limit => limit.isRateLimited && limit.resetTime > Date.now()
340
+ );
341
+ });
342
+
343
+ return {
344
+ total: this.#accounts.length,
345
+ available: available.length,
346
+ rateLimited: rateLimited.length,
347
+ invalid: invalid.length,
348
+ summary: `${this.#accounts.length} total, ${available.length} available, ${rateLimited.length} rate-limited, ${invalid.length} invalid`,
349
+ accounts: this.#accounts.map(a => ({
350
+ email: a.email,
351
+ source: a.source,
352
+ enabled: a.enabled !== false, // Default to true if undefined
353
+ projectId: a.projectId || null,
354
+ modelRateLimits: a.modelRateLimits || {},
355
+ isInvalid: a.isInvalid || false,
356
+ invalidReason: a.invalidReason || null,
357
+ lastUsed: a.lastUsed
358
+ }))
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Get settings
364
+ * @returns {Object} Current settings object
365
+ */
366
+ getSettings() {
367
+ return { ...this.#settings };
368
+ }
369
+
370
+ /**
371
+ * Get all accounts (internal use for quota fetching)
372
+ * Returns the full account objects including credentials
373
+ * @returns {Array<Object>} Array of account objects
374
+ */
375
+ getAllAccounts() {
376
+ return this.#accounts;
377
+ }
378
+ }
379
+
380
+ export default AccountManager;