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,339 @@
1
+ /**
2
+ * Hybrid Strategy
3
+ *
4
+ * Smart selection based on health score, token bucket, quota, and LRU freshness.
5
+ * Combines multiple signals for optimal account distribution.
6
+ *
7
+ * Scoring formula:
8
+ * score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 3) + (LRU × 0.1)
9
+ *
10
+ * Filters accounts that are:
11
+ * - Not rate-limited
12
+ * - Not invalid or disabled
13
+ * - Health score >= minUsable
14
+ * - Has tokens available
15
+ * - Quota not critically low (< 5%)
16
+ */
17
+
18
+ import { BaseStrategy } from './base-strategy.js';
19
+ import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js';
20
+ import { logger } from '../../utils/logger.js';
21
+
22
+ // Default weights for scoring
23
+ const DEFAULT_WEIGHTS = {
24
+ health: 2,
25
+ tokens: 5,
26
+ quota: 3,
27
+ lru: 0.1
28
+ };
29
+
30
+ export class HybridStrategy extends BaseStrategy {
31
+ #healthTracker;
32
+ #tokenBucketTracker;
33
+ #quotaTracker;
34
+ #weights;
35
+
36
+ /**
37
+ * Create a new HybridStrategy
38
+ * @param {Object} config - Strategy configuration
39
+ * @param {Object} [config.healthScore] - Health tracker configuration
40
+ * @param {Object} [config.tokenBucket] - Token bucket configuration
41
+ * @param {Object} [config.quota] - Quota tracker configuration
42
+ * @param {Object} [config.weights] - Scoring weights
43
+ */
44
+ constructor(config = {}) {
45
+ super(config);
46
+ this.#healthTracker = new HealthTracker(config.healthScore || {});
47
+ this.#tokenBucketTracker = new TokenBucketTracker(config.tokenBucket || {});
48
+ this.#quotaTracker = new QuotaTracker(config.quota || {});
49
+ this.#weights = { ...DEFAULT_WEIGHTS, ...config.weights };
50
+ }
51
+
52
+ /**
53
+ * Select an account based on combined health, tokens, and LRU score
54
+ *
55
+ * @param {Array} accounts - Array of account objects
56
+ * @param {string} modelId - The model ID for the request
57
+ * @param {Object} options - Additional options
58
+ * @returns {SelectionResult} The selected account and index
59
+ */
60
+ selectAccount(accounts, modelId, options = {}) {
61
+ const { onSave } = options;
62
+
63
+ if (accounts.length === 0) {
64
+ return { account: null, index: 0, waitMs: 0 };
65
+ }
66
+
67
+ // Get candidates that pass all filters
68
+ const { candidates, fallbackLevel } = this.#getCandidates(accounts, modelId);
69
+
70
+ if (candidates.length === 0) {
71
+ // Diagnose why no candidates are available and compute wait time
72
+ const { reason, waitMs } = this.#diagnoseNoCandidates(accounts, modelId);
73
+ logger.warn(`[HybridStrategy] No candidates available: ${reason}`);
74
+ return { account: null, index: 0, waitMs };
75
+ }
76
+
77
+ // Score and sort candidates
78
+ const scored = candidates.map(({ account, index }) => ({
79
+ account,
80
+ index,
81
+ score: this.#calculateScore(account, modelId)
82
+ }));
83
+
84
+ scored.sort((a, b) => b.score - a.score);
85
+
86
+ // Select the best candidate
87
+ const best = scored[0];
88
+ best.account.lastUsed = Date.now();
89
+
90
+ // Consume a token from the bucket (unless in lastResort mode where we bypassed token check)
91
+ if (fallbackLevel !== 'lastResort') {
92
+ this.#tokenBucketTracker.consume(best.account.email);
93
+ }
94
+
95
+ if (onSave) onSave();
96
+
97
+ // Calculate throttle wait time based on fallback level
98
+ // This prevents overwhelming the API when all accounts are stressed
99
+ let waitMs = 0;
100
+ if (fallbackLevel === 'lastResort') {
101
+ // All accounts exhausted - add significant delay to allow rate limits to clear
102
+ waitMs = 500;
103
+ } else if (fallbackLevel === 'emergency') {
104
+ // All accounts unhealthy - add moderate delay
105
+ waitMs = 250;
106
+ }
107
+
108
+ const position = best.index + 1;
109
+ const total = accounts.length;
110
+ const fallbackInfo = fallbackLevel !== 'normal' ? `, fallback: ${fallbackLevel}` : '';
111
+ logger.info(`[HybridStrategy] Using account: ${best.account.email} (${position}/${total}, score: ${best.score.toFixed(1)}${fallbackInfo})`);
112
+
113
+ return { account: best.account, index: best.index, waitMs };
114
+ }
115
+
116
+ /**
117
+ * Called after a successful request
118
+ */
119
+ onSuccess(account, modelId) {
120
+ if (account && account.email) {
121
+ this.#healthTracker.recordSuccess(account.email);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Called when a request is rate-limited
127
+ */
128
+ onRateLimit(account, modelId) {
129
+ if (account && account.email) {
130
+ this.#healthTracker.recordRateLimit(account.email);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Called when a request fails
136
+ */
137
+ onFailure(account, modelId) {
138
+ if (account && account.email) {
139
+ this.#healthTracker.recordFailure(account.email);
140
+ // Refund the token since the request didn't complete
141
+ this.#tokenBucketTracker.refund(account.email);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get candidates that pass all filters
147
+ * @private
148
+ * @returns {{candidates: Array, fallbackLevel: string}} Candidates and fallback level used
149
+ * fallbackLevel: 'normal' | 'quota' | 'emergency' | 'lastResort'
150
+ */
151
+ #getCandidates(accounts, modelId) {
152
+ const candidates = accounts
153
+ .map((account, index) => ({ account, index }))
154
+ .filter(({ account }) => {
155
+ // Basic usability check
156
+ if (!this.isAccountUsable(account, modelId)) {
157
+ return false;
158
+ }
159
+
160
+ // Health score check
161
+ if (!this.#healthTracker.isUsable(account.email)) {
162
+ return false;
163
+ }
164
+
165
+ // Token availability check
166
+ if (!this.#tokenBucketTracker.hasTokens(account.email)) {
167
+ return false;
168
+ }
169
+
170
+ // Quota availability check (exclude critically low quota)
171
+ if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
172
+ logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId}`);
173
+ return false;
174
+ }
175
+
176
+ return true;
177
+ });
178
+
179
+ if (candidates.length > 0) {
180
+ return { candidates, fallbackLevel: 'normal' };
181
+ }
182
+
183
+ // If no candidates after quota filter, fall back to all usable accounts
184
+ // (better to use critical quota than fail entirely)
185
+ const fallback = accounts
186
+ .map((account, index) => ({ account, index }))
187
+ .filter(({ account }) => {
188
+ if (!this.isAccountUsable(account, modelId)) return false;
189
+ if (!this.#healthTracker.isUsable(account.email)) return false;
190
+ if (!this.#tokenBucketTracker.hasTokens(account.email)) return false;
191
+ return true;
192
+ });
193
+ if (fallback.length > 0) {
194
+ logger.warn('[HybridStrategy] All accounts have critical quota, using fallback');
195
+ return { candidates: fallback, fallbackLevel: 'quota' };
196
+ }
197
+
198
+ // Emergency fallback: bypass health check when ALL accounts are unhealthy
199
+ // This prevents "Max retries exceeded" when health scores are too low
200
+ const emergency = accounts
201
+ .map((account, index) => ({ account, index }))
202
+ .filter(({ account }) => {
203
+ if (!this.isAccountUsable(account, modelId)) return false;
204
+ if (!this.#tokenBucketTracker.hasTokens(account.email)) return false;
205
+ // Skip health check - use "least bad" account
206
+ return true;
207
+ });
208
+ if (emergency.length > 0) {
209
+ logger.warn('[HybridStrategy] EMERGENCY: All accounts unhealthy, using least bad account');
210
+ return { candidates: emergency, fallbackLevel: 'emergency' };
211
+ }
212
+
213
+ // Last resort: bypass BOTH health AND token bucket checks
214
+ // Only check basic usability (not rate-limited, not disabled)
215
+ const lastResort = accounts
216
+ .map((account, index) => ({ account, index }))
217
+ .filter(({ account }) => {
218
+ // Only check if account is usable (not rate-limited, not disabled)
219
+ if (!this.isAccountUsable(account, modelId)) return false;
220
+ // Skip health and token bucket checks entirely
221
+ return true;
222
+ });
223
+ if (lastResort.length > 0) {
224
+ logger.warn('[HybridStrategy] LAST RESORT: All accounts exhausted, using any usable account');
225
+ return { candidates: lastResort, fallbackLevel: 'lastResort' };
226
+ }
227
+
228
+ return { candidates: [], fallbackLevel: 'normal' };
229
+ }
230
+
231
+ /**
232
+ * Calculate the combined score for an account
233
+ * @private
234
+ */
235
+ #calculateScore(account, modelId) {
236
+ const email = account.email;
237
+
238
+ // Health component (0-100 scaled by weight)
239
+ const health = this.#healthTracker.getScore(email);
240
+ const healthComponent = health * this.#weights.health;
241
+
242
+ // Token component (0-100 scaled by weight)
243
+ const tokens = this.#tokenBucketTracker.getTokens(email);
244
+ const maxTokens = this.#tokenBucketTracker.getMaxTokens();
245
+ const tokenRatio = tokens / maxTokens;
246
+ const tokenComponent = (tokenRatio * 100) * this.#weights.tokens;
247
+
248
+ // Quota component (0-100 scaled by weight)
249
+ const quotaScore = this.#quotaTracker.getScore(account, modelId);
250
+ const quotaComponent = quotaScore * this.#weights.quota;
251
+
252
+ // LRU component (older = higher score)
253
+ // Use time since last use in seconds, capped at 1 hour (matches opencode-cloudcode-auth)
254
+ const lastUsed = account.lastUsed || 0;
255
+ const timeSinceLastUse = Math.min(Date.now() - lastUsed, 3600000); // Cap at 1 hour
256
+ const lruSeconds = timeSinceLastUse / 1000;
257
+ const lruComponent = lruSeconds * this.#weights.lru; // 0-3600 * 0.1 = 0-360 max
258
+
259
+ return healthComponent + tokenComponent + quotaComponent + lruComponent;
260
+ }
261
+
262
+ /**
263
+ * Get the health tracker (for testing/debugging)
264
+ * @returns {HealthTracker} The health tracker instance
265
+ */
266
+ getHealthTracker() {
267
+ return this.#healthTracker;
268
+ }
269
+
270
+ /**
271
+ * Get the token bucket tracker (for testing/debugging)
272
+ * @returns {TokenBucketTracker} The token bucket tracker instance
273
+ */
274
+ getTokenBucketTracker() {
275
+ return this.#tokenBucketTracker;
276
+ }
277
+
278
+ /**
279
+ * Get the quota tracker (for testing/debugging)
280
+ * @returns {QuotaTracker} The quota tracker instance
281
+ */
282
+ getQuotaTracker() {
283
+ return this.#quotaTracker;
284
+ }
285
+
286
+ /**
287
+ * Diagnose why no candidates are available and compute wait time
288
+ * @private
289
+ * @param {Array} accounts - Array of account objects
290
+ * @param {string} modelId - The model ID
291
+ * @returns {{reason: string, waitMs: number}} Diagnosis result
292
+ */
293
+ #diagnoseNoCandidates(accounts, modelId) {
294
+ let unusableCount = 0;
295
+ let unhealthyCount = 0;
296
+ let noTokensCount = 0;
297
+ let criticalQuotaCount = 0;
298
+ const accountsWithoutTokens = [];
299
+
300
+ for (const account of accounts) {
301
+ if (!this.isAccountUsable(account, modelId)) {
302
+ unusableCount++;
303
+ continue;
304
+ }
305
+ if (!this.#healthTracker.isUsable(account.email)) {
306
+ unhealthyCount++;
307
+ continue;
308
+ }
309
+ if (!this.#tokenBucketTracker.hasTokens(account.email)) {
310
+ noTokensCount++;
311
+ accountsWithoutTokens.push(account.email);
312
+ continue;
313
+ }
314
+ if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
315
+ criticalQuotaCount++;
316
+ continue;
317
+ }
318
+ }
319
+
320
+ // If all accounts are blocked by token bucket, calculate wait time
321
+ if (noTokensCount > 0 && unusableCount === 0 && unhealthyCount === 0) {
322
+ const waitMs = this.#tokenBucketTracker.getMinTimeUntilToken(accountsWithoutTokens);
323
+ const reason = `all ${noTokensCount} account(s) exhausted token bucket, waiting for refill`;
324
+ return { reason, waitMs };
325
+ }
326
+
327
+ // Build reason string
328
+ const parts = [];
329
+ if (unusableCount > 0) parts.push(`${unusableCount} unusable/disabled`);
330
+ if (unhealthyCount > 0) parts.push(`${unhealthyCount} unhealthy`);
331
+ if (noTokensCount > 0) parts.push(`${noTokensCount} no tokens`);
332
+ if (criticalQuotaCount > 0) parts.push(`${criticalQuotaCount} critical quota`);
333
+
334
+ const reason = parts.length > 0 ? parts.join(', ') : 'unknown';
335
+ return { reason, waitMs: 0 };
336
+ }
337
+ }
338
+
339
+ export default HybridStrategy;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Strategy Factory
3
+ *
4
+ * Creates and exports account selection strategy instances.
5
+ */
6
+
7
+ import { StickyStrategy } from './sticky-strategy.js';
8
+ import { RoundRobinStrategy } from './round-robin-strategy.js';
9
+ import { HybridStrategy } from './hybrid-strategy.js';
10
+ import { logger } from '../../utils/logger.js';
11
+ import {
12
+ SELECTION_STRATEGIES,
13
+ DEFAULT_SELECTION_STRATEGY,
14
+ STRATEGY_LABELS
15
+ } from '../../constants.js';
16
+
17
+ // Re-export strategy constants for convenience
18
+ export const STRATEGY_NAMES = SELECTION_STRATEGIES;
19
+ export const DEFAULT_STRATEGY = DEFAULT_SELECTION_STRATEGY;
20
+
21
+ /**
22
+ * Create a strategy instance
23
+ * @param {string} strategyName - Name of the strategy ('sticky', 'round-robin', 'hybrid')
24
+ * @param {Object} config - Strategy configuration
25
+ * @returns {BaseStrategy} The strategy instance
26
+ */
27
+ export function createStrategy(strategyName, config = {}) {
28
+ const name = (strategyName || DEFAULT_STRATEGY).toLowerCase();
29
+
30
+ switch (name) {
31
+ case 'sticky':
32
+ logger.debug('[Strategy] Creating StickyStrategy');
33
+ return new StickyStrategy(config);
34
+
35
+ case 'round-robin':
36
+ case 'roundrobin':
37
+ logger.debug('[Strategy] Creating RoundRobinStrategy');
38
+ return new RoundRobinStrategy(config);
39
+
40
+ case 'hybrid':
41
+ logger.debug('[Strategy] Creating HybridStrategy');
42
+ return new HybridStrategy(config);
43
+
44
+ default:
45
+ logger.warn(`[Strategy] Unknown strategy "${strategyName}", falling back to ${DEFAULT_STRATEGY}`);
46
+ return new HybridStrategy(config);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Check if a strategy name is valid
52
+ * @param {string} name - Strategy name to check
53
+ * @returns {boolean} True if valid
54
+ */
55
+ export function isValidStrategy(name) {
56
+ if (!name) return false;
57
+ const lower = name.toLowerCase();
58
+ return STRATEGY_NAMES.includes(lower) || lower === 'roundrobin';
59
+ }
60
+
61
+ /**
62
+ * Get the display label for a strategy
63
+ * @param {string} name - Strategy name
64
+ * @returns {string} Display label
65
+ */
66
+ export function getStrategyLabel(name) {
67
+ const lower = (name || DEFAULT_STRATEGY).toLowerCase();
68
+ if (lower === 'roundrobin') return STRATEGY_LABELS['round-robin'];
69
+ return STRATEGY_LABELS[lower] || STRATEGY_LABELS[DEFAULT_STRATEGY];
70
+ }
71
+
72
+ // Re-export strategies for direct use
73
+ export { StickyStrategy } from './sticky-strategy.js';
74
+ export { RoundRobinStrategy } from './round-robin-strategy.js';
75
+ export { HybridStrategy } from './hybrid-strategy.js';
76
+ export { BaseStrategy } from './base-strategy.js';
77
+
78
+ // Re-export trackers
79
+ export { HealthTracker, TokenBucketTracker } from './trackers/index.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Round-Robin Strategy
3
+ *
4
+ * Rotates to the next account on every request for maximum throughput.
5
+ * Does not maintain cache continuity but maximizes concurrent requests.
6
+ */
7
+
8
+ import { BaseStrategy } from './base-strategy.js';
9
+ import { logger } from '../../utils/logger.js';
10
+
11
+ export class RoundRobinStrategy extends BaseStrategy {
12
+ #cursor = 0; // Tracks current position in rotation
13
+
14
+ /**
15
+ * Create a new RoundRobinStrategy
16
+ * @param {Object} config - Strategy configuration
17
+ */
18
+ constructor(config = {}) {
19
+ super(config);
20
+ }
21
+
22
+ /**
23
+ * Select the next available account in rotation
24
+ *
25
+ * @param {Array} accounts - Array of account objects
26
+ * @param {string} modelId - The model ID for the request
27
+ * @param {Object} options - Additional options
28
+ * @returns {SelectionResult} The selected account and index
29
+ */
30
+ selectAccount(accounts, modelId, options = {}) {
31
+ const { onSave } = options;
32
+
33
+ if (accounts.length === 0) {
34
+ return { account: null, index: 0, waitMs: 0 };
35
+ }
36
+
37
+ // Clamp cursor to valid range
38
+ if (this.#cursor >= accounts.length) {
39
+ this.#cursor = 0;
40
+ }
41
+
42
+ // Start from the next position after the cursor
43
+ const startIndex = (this.#cursor + 1) % accounts.length;
44
+
45
+ // Try each account starting from startIndex
46
+ for (let i = 0; i < accounts.length; i++) {
47
+ const idx = (startIndex + i) % accounts.length;
48
+ const account = accounts[idx];
49
+
50
+ if (this.isAccountUsable(account, modelId)) {
51
+ account.lastUsed = Date.now();
52
+ this.#cursor = idx;
53
+
54
+ if (onSave) onSave();
55
+
56
+ const position = idx + 1;
57
+ const total = accounts.length;
58
+ logger.info(`[RoundRobinStrategy] Using account: ${account.email} (${position}/${total})`);
59
+
60
+ return { account, index: idx, waitMs: 0 };
61
+ }
62
+ }
63
+
64
+ // No usable accounts found
65
+ return { account: null, index: this.#cursor, waitMs: 0 };
66
+ }
67
+
68
+ /**
69
+ * Reset the cursor position
70
+ */
71
+ resetCursor() {
72
+ this.#cursor = 0;
73
+ }
74
+ }
75
+
76
+ export default RoundRobinStrategy;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Sticky Strategy
3
+ *
4
+ * Keeps using the same account until it becomes unavailable (rate-limited or invalid).
5
+ * Best for prompt caching as it maintains cache continuity across requests.
6
+ */
7
+
8
+ import { BaseStrategy } from './base-strategy.js';
9
+ import { logger } from '../../utils/logger.js';
10
+ import { formatDuration } from '../../utils/helpers.js';
11
+ import { MAX_WAIT_BEFORE_ERROR_MS } from '../../constants.js';
12
+
13
+ export class StickyStrategy extends BaseStrategy {
14
+ /**
15
+ * Create a new StickyStrategy
16
+ * @param {Object} config - Strategy configuration
17
+ */
18
+ constructor(config = {}) {
19
+ super(config);
20
+ }
21
+
22
+ /**
23
+ * Select an account with sticky preference
24
+ * Prefers the current account for cache continuity, only switches when:
25
+ * - Current account is rate-limited for > 2 minutes
26
+ * - Current account is invalid
27
+ * - Current account is disabled
28
+ *
29
+ * @param {Array} accounts - Array of account objects
30
+ * @param {string} modelId - The model ID for the request
31
+ * @param {Object} options - Additional options
32
+ * @returns {SelectionResult} The selected account and index
33
+ */
34
+ selectAccount(accounts, modelId, options = {}) {
35
+ const { currentIndex = 0, onSave } = options;
36
+
37
+ if (accounts.length === 0) {
38
+ return { account: null, index: currentIndex, waitMs: 0 };
39
+ }
40
+
41
+ // Clamp index to valid range
42
+ let index = currentIndex >= accounts.length ? 0 : currentIndex;
43
+ const currentAccount = accounts[index];
44
+
45
+ // Check if current account is usable
46
+ if (this.isAccountUsable(currentAccount, modelId)) {
47
+ currentAccount.lastUsed = Date.now();
48
+ if (onSave) onSave();
49
+ return { account: currentAccount, index, waitMs: 0 };
50
+ }
51
+
52
+ // Current account is not usable - check if others are available
53
+ const usableAccounts = this.getUsableAccounts(accounts, modelId);
54
+
55
+ if (usableAccounts.length > 0) {
56
+ // Found a free account - switch immediately
57
+ const { account: nextAccount, index: nextIndex } = this.#pickNext(
58
+ accounts,
59
+ index,
60
+ modelId,
61
+ onSave
62
+ );
63
+ if (nextAccount) {
64
+ logger.info(`[StickyStrategy] Switched to new account (failover): ${nextAccount.email}`);
65
+ return { account: nextAccount, index: nextIndex, waitMs: 0 };
66
+ }
67
+ }
68
+
69
+ // No other accounts available - check if we should wait for current
70
+ const waitInfo = this.#shouldWaitForAccount(currentAccount, modelId);
71
+ if (waitInfo.shouldWait) {
72
+ logger.info(`[StickyStrategy] Waiting ${formatDuration(waitInfo.waitMs)} for sticky account: ${currentAccount.email}`);
73
+ return { account: null, index, waitMs: waitInfo.waitMs };
74
+ }
75
+
76
+ // Current account unavailable for too long, try to find any other
77
+ const { account: nextAccount, index: nextIndex } = this.#pickNext(
78
+ accounts,
79
+ index,
80
+ modelId,
81
+ onSave
82
+ );
83
+
84
+ return { account: nextAccount, index: nextIndex, waitMs: 0 };
85
+ }
86
+
87
+ /**
88
+ * Pick the next available account starting from after the current index
89
+ * @private
90
+ */
91
+ #pickNext(accounts, currentIndex, modelId, onSave) {
92
+ for (let i = 1; i <= accounts.length; i++) {
93
+ const idx = (currentIndex + i) % accounts.length;
94
+ const account = accounts[idx];
95
+
96
+ if (this.isAccountUsable(account, modelId)) {
97
+ account.lastUsed = Date.now();
98
+ if (onSave) onSave();
99
+
100
+ const position = idx + 1;
101
+ const total = accounts.length;
102
+ logger.info(`[StickyStrategy] Using account: ${account.email} (${position}/${total})`);
103
+
104
+ return { account, index: idx };
105
+ }
106
+ }
107
+
108
+ return { account: null, index: currentIndex };
109
+ }
110
+
111
+ /**
112
+ * Check if we should wait for an account's rate limit to reset
113
+ * @private
114
+ */
115
+ #shouldWaitForAccount(account, modelId) {
116
+ if (!account || account.isInvalid || account.enabled === false) {
117
+ return { shouldWait: false, waitMs: 0 };
118
+ }
119
+
120
+ let waitMs = 0;
121
+
122
+ if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
123
+ const limit = account.modelRateLimits[modelId];
124
+ if (limit.isRateLimited && limit.resetTime) {
125
+ waitMs = limit.resetTime - Date.now();
126
+ }
127
+ }
128
+
129
+ // Wait if within threshold
130
+ if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
131
+ return { shouldWait: true, waitMs };
132
+ }
133
+
134
+ return { shouldWait: false, waitMs: 0 };
135
+ }
136
+ }
137
+
138
+ export default StickyStrategy;