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.
- package/LICENSE +21 -0
- package/README.md +757 -0
- package/bin/cli.js +146 -0
- package/package.json +97 -0
- package/public/Complaint Details.pdf +0 -0
- package/public/Cyber Crime Portal.pdf +0 -0
- package/public/app.js +229 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.png +0 -0
- package/public/index.html +549 -0
- package/public/js/components/account-manager.js +356 -0
- package/public/js/components/add-account-modal.js +414 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +605 -0
- package/public/js/components/dashboard/filters.js +362 -0
- package/public/js/components/dashboard/stats.js +110 -0
- package/public/js/components/dashboard.js +236 -0
- package/public/js/components/logs-viewer.js +100 -0
- package/public/js/components/models.js +36 -0
- package/public/js/components/server-config.js +349 -0
- package/public/js/config/constants.js +102 -0
- package/public/js/data-store.js +375 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +99 -0
- package/public/js/translations/en.js +367 -0
- package/public/js/translations/id.js +412 -0
- package/public/js/translations/pt.js +308 -0
- package/public/js/translations/tr.js +358 -0
- package/public/js/translations/zh.js +373 -0
- package/public/js/utils/account-actions.js +189 -0
- package/public/js/utils/error-handler.js +96 -0
- package/public/js/utils/model-config.js +42 -0
- package/public/js/utils/ui-logger.js +143 -0
- package/public/js/utils/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/proxy-server-64.png +0 -0
- package/public/views/accounts.html +361 -0
- package/public/views/dashboard.html +484 -0
- package/public/views/logs.html +97 -0
- package/public/views/models.html +331 -0
- package/public/views/settings.html +1327 -0
- package/src/account-manager/credentials.js +378 -0
- package/src/account-manager/index.js +462 -0
- package/src/account-manager/onboarding.js +112 -0
- package/src/account-manager/rate-limits.js +369 -0
- package/src/account-manager/storage.js +160 -0
- package/src/account-manager/strategies/base-strategy.js +109 -0
- package/src/account-manager/strategies/hybrid-strategy.js +339 -0
- package/src/account-manager/strategies/index.js +79 -0
- package/src/account-manager/strategies/round-robin-strategy.js +76 -0
- package/src/account-manager/strategies/sticky-strategy.js +138 -0
- package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
- package/src/account-manager/strategies/trackers/index.js +9 -0
- package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +548 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +648 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +510 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +235 -0
- package/src/cloudcode/request-builder.js +93 -0
- package/src/cloudcode/session-manager.js +47 -0
- package/src/cloudcode/sse-parser.js +121 -0
- package/src/cloudcode/sse-streamer.js +293 -0
- package/src/cloudcode/streaming-handler.js +615 -0
- package/src/config.js +125 -0
- package/src/constants.js +407 -0
- package/src/errors.js +242 -0
- package/src/fallback-config.js +29 -0
- package/src/format/content-converter.js +193 -0
- package/src/format/index.js +20 -0
- package/src/format/request-converter.js +255 -0
- package/src/format/response-converter.js +120 -0
- package/src/format/schema-sanitizer.js +673 -0
- package/src/format/signature-cache.js +88 -0
- package/src/format/thinking-utils.js +648 -0
- package/src/index.js +148 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/providers/anthropic-provider.js +258 -0
- package/src/providers/base-provider.js +157 -0
- package/src/providers/cloudcode.js +94 -0
- package/src/providers/copilot.js +399 -0
- package/src/providers/github-provider.js +287 -0
- package/src/providers/google-provider.js +192 -0
- package/src/providers/index.js +211 -0
- package/src/providers/openai-compatible.js +265 -0
- package/src/providers/openai-provider.js +271 -0
- package/src/providers/openrouter-provider.js +325 -0
- package/src/providers/setup.js +83 -0
- package/src/server.js +870 -0
- package/src/utils/claude-config.js +245 -0
- package/src/utils/helpers.js +51 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/native-module-helper.js +162 -0
- 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;
|