@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.
- package/LICENSE +21 -0
- package/README.md +622 -0
- package/bin/cli.js +124 -0
- package/package.json +80 -0
- package/public/app.js +228 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.svg +10 -0
- package/public/index.html +381 -0
- package/public/js/components/account-manager.js +245 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +589 -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 +386 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +78 -0
- package/public/js/translations/en.js +351 -0
- package/public/js/translations/id.js +396 -0
- package/public/js/translations/pt.js +287 -0
- package/public/js/translations/tr.js +342 -0
- package/public/js/translations/zh.js +357 -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/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/views/accounts.html +329 -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 +1329 -0
- package/src/account-manager/credentials.js +243 -0
- package/src/account-manager/index.js +380 -0
- package/src/account-manager/onboarding.js +117 -0
- package/src/account-manager/rate-limits.js +237 -0
- package/src/account-manager/storage.js +136 -0
- package/src/account-manager/strategies/base-strategy.js +104 -0
- package/src/account-manager/strategies/hybrid-strategy.js +195 -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 +8 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +121 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +419 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +512 -0
- package/src/cli/refresh.js +201 -0
- package/src/cli/setup.js +338 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +386 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +181 -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 +492 -0
- package/src/config.js +107 -0
- package/src/constants.js +278 -0
- package/src/errors.js +238 -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 +248 -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 +558 -0
- package/src/index.js +146 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/server.js +861 -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 +707 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Strategy
|
|
3
|
+
*
|
|
4
|
+
* Smart selection based on health score, token bucket, and LRU freshness.
|
|
5
|
+
* Combines multiple signals for optimal account distribution.
|
|
6
|
+
*
|
|
7
|
+
* Scoring formula:
|
|
8
|
+
* score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BaseStrategy } from './base-strategy.js';
|
|
18
|
+
import { HealthTracker, TokenBucketTracker } from './trackers/index.js';
|
|
19
|
+
import { logger } from '../../utils/logger.js';
|
|
20
|
+
|
|
21
|
+
// Default weights for scoring
|
|
22
|
+
const DEFAULT_WEIGHTS = {
|
|
23
|
+
health: 2,
|
|
24
|
+
tokens: 5,
|
|
25
|
+
lru: 0.1
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class HybridStrategy extends BaseStrategy {
|
|
29
|
+
#healthTracker;
|
|
30
|
+
#tokenBucketTracker;
|
|
31
|
+
#weights;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a new HybridStrategy
|
|
35
|
+
* @param {Object} config - Strategy configuration
|
|
36
|
+
* @param {Object} [config.healthScore] - Health tracker configuration
|
|
37
|
+
* @param {Object} [config.tokenBucket] - Token bucket configuration
|
|
38
|
+
* @param {Object} [config.weights] - Scoring weights
|
|
39
|
+
*/
|
|
40
|
+
constructor(config = {}) {
|
|
41
|
+
super(config);
|
|
42
|
+
this.#healthTracker = new HealthTracker(config.healthScore || {});
|
|
43
|
+
this.#tokenBucketTracker = new TokenBucketTracker(config.tokenBucket || {});
|
|
44
|
+
this.#weights = { ...DEFAULT_WEIGHTS, ...config.weights };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Select an account based on combined health, tokens, and LRU score
|
|
49
|
+
*
|
|
50
|
+
* @param {Array} accounts - Array of account objects
|
|
51
|
+
* @param {string} modelId - The model ID for the request
|
|
52
|
+
* @param {Object} options - Additional options
|
|
53
|
+
* @returns {SelectionResult} The selected account and index
|
|
54
|
+
*/
|
|
55
|
+
selectAccount(accounts, modelId, options = {}) {
|
|
56
|
+
const { onSave } = options;
|
|
57
|
+
|
|
58
|
+
if (accounts.length === 0) {
|
|
59
|
+
return { account: null, index: 0, waitMs: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Get candidates that pass all filters
|
|
63
|
+
const candidates = this.#getCandidates(accounts, modelId);
|
|
64
|
+
|
|
65
|
+
if (candidates.length === 0) {
|
|
66
|
+
logger.debug('[HybridStrategy] No candidates available');
|
|
67
|
+
return { account: null, index: 0, waitMs: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Score and sort candidates
|
|
71
|
+
const scored = candidates.map(({ account, index }) => ({
|
|
72
|
+
account,
|
|
73
|
+
index,
|
|
74
|
+
score: this.#calculateScore(account)
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
scored.sort((a, b) => b.score - a.score);
|
|
78
|
+
|
|
79
|
+
// Select the best candidate
|
|
80
|
+
const best = scored[0];
|
|
81
|
+
best.account.lastUsed = Date.now();
|
|
82
|
+
|
|
83
|
+
// Consume a token from the bucket
|
|
84
|
+
this.#tokenBucketTracker.consume(best.account.email);
|
|
85
|
+
|
|
86
|
+
if (onSave) onSave();
|
|
87
|
+
|
|
88
|
+
const position = best.index + 1;
|
|
89
|
+
const total = accounts.length;
|
|
90
|
+
logger.info(`[HybridStrategy] Using account: ${best.account.email} (${position}/${total}, score: ${best.score.toFixed(1)})`);
|
|
91
|
+
|
|
92
|
+
return { account: best.account, index: best.index, waitMs: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Called after a successful request
|
|
97
|
+
*/
|
|
98
|
+
onSuccess(account, modelId) {
|
|
99
|
+
if (account && account.email) {
|
|
100
|
+
this.#healthTracker.recordSuccess(account.email);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Called when a request is rate-limited
|
|
106
|
+
*/
|
|
107
|
+
onRateLimit(account, modelId) {
|
|
108
|
+
if (account && account.email) {
|
|
109
|
+
this.#healthTracker.recordRateLimit(account.email);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Called when a request fails
|
|
115
|
+
*/
|
|
116
|
+
onFailure(account, modelId) {
|
|
117
|
+
if (account && account.email) {
|
|
118
|
+
this.#healthTracker.recordFailure(account.email);
|
|
119
|
+
// Refund the token since the request didn't complete
|
|
120
|
+
this.#tokenBucketTracker.refund(account.email);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get candidates that pass all filters
|
|
126
|
+
* @private
|
|
127
|
+
*/
|
|
128
|
+
#getCandidates(accounts, modelId) {
|
|
129
|
+
return accounts
|
|
130
|
+
.map((account, index) => ({ account, index }))
|
|
131
|
+
.filter(({ account }) => {
|
|
132
|
+
// Basic usability check
|
|
133
|
+
if (!this.isAccountUsable(account, modelId)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Health score check
|
|
138
|
+
if (!this.#healthTracker.isUsable(account.email)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Token availability check
|
|
143
|
+
if (!this.#tokenBucketTracker.hasTokens(account.email)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Calculate the combined score for an account
|
|
153
|
+
* @private
|
|
154
|
+
*/
|
|
155
|
+
#calculateScore(account) {
|
|
156
|
+
const email = account.email;
|
|
157
|
+
|
|
158
|
+
// Health component (0-100 scaled by weight)
|
|
159
|
+
const health = this.#healthTracker.getScore(email);
|
|
160
|
+
const healthComponent = health * this.#weights.health;
|
|
161
|
+
|
|
162
|
+
// Token component (0-100 scaled by weight)
|
|
163
|
+
const tokens = this.#tokenBucketTracker.getTokens(email);
|
|
164
|
+
const maxTokens = this.#tokenBucketTracker.getMaxTokens();
|
|
165
|
+
const tokenRatio = tokens / maxTokens;
|
|
166
|
+
const tokenComponent = (tokenRatio * 100) * this.#weights.tokens;
|
|
167
|
+
|
|
168
|
+
// LRU component (older = higher score)
|
|
169
|
+
// Use time since last use, capped at 1 hour for scoring
|
|
170
|
+
const lastUsed = account.lastUsed || 0;
|
|
171
|
+
const timeSinceLastUse = Math.min(Date.now() - lastUsed, 3600000); // Cap at 1 hour
|
|
172
|
+
const lruMinutes = timeSinceLastUse / 60000;
|
|
173
|
+
const lruComponent = lruMinutes * this.#weights.lru;
|
|
174
|
+
|
|
175
|
+
return healthComponent + tokenComponent + lruComponent;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get the health tracker (for testing/debugging)
|
|
180
|
+
* @returns {HealthTracker} The health tracker instance
|
|
181
|
+
*/
|
|
182
|
+
getHealthTracker() {
|
|
183
|
+
return this.#healthTracker;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the token bucket tracker (for testing/debugging)
|
|
188
|
+
* @returns {TokenBucketTracker} The token bucket tracker instance
|
|
189
|
+
*/
|
|
190
|
+
getTokenBucketTracker() {
|
|
191
|
+
return this.#tokenBucketTracker;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
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;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Tracker
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-account health scores to prioritize healthy accounts.
|
|
5
|
+
* Scores increase on success and decrease on failures/rate limits.
|
|
6
|
+
* Passive recovery over time helps accounts recover from temporary issues.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Default configuration (matches opencode-antigravity-auth)
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
initial: 70, // Starting score for new accounts
|
|
12
|
+
successReward: 1, // Points on successful request
|
|
13
|
+
rateLimitPenalty: -10, // Points on rate limit
|
|
14
|
+
failurePenalty: -20, // Points on other failures
|
|
15
|
+
recoveryPerHour: 2, // Passive recovery rate
|
|
16
|
+
minUsable: 50, // Minimum score to be selected
|
|
17
|
+
maxScore: 100 // Maximum score cap
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class HealthTracker {
|
|
21
|
+
#scores = new Map(); // email -> { score, lastUpdated, consecutiveFailures }
|
|
22
|
+
#config;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a new HealthTracker
|
|
26
|
+
* @param {Object} config - Health score configuration
|
|
27
|
+
*/
|
|
28
|
+
constructor(config = {}) {
|
|
29
|
+
this.#config = { ...DEFAULT_CONFIG, ...config };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the health score for an account
|
|
34
|
+
* @param {string} email - Account email
|
|
35
|
+
* @returns {number} Current health score (with passive recovery applied)
|
|
36
|
+
*/
|
|
37
|
+
getScore(email) {
|
|
38
|
+
const record = this.#scores.get(email);
|
|
39
|
+
if (!record) {
|
|
40
|
+
return this.#config.initial;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Apply passive recovery based on time elapsed
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const hoursElapsed = (now - record.lastUpdated) / (1000 * 60 * 60);
|
|
46
|
+
const recovery = hoursElapsed * this.#config.recoveryPerHour;
|
|
47
|
+
const recoveredScore = Math.min(
|
|
48
|
+
this.#config.maxScore,
|
|
49
|
+
record.score + recovery
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return recoveredScore;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record a successful request for an account
|
|
57
|
+
* @param {string} email - Account email
|
|
58
|
+
*/
|
|
59
|
+
recordSuccess(email) {
|
|
60
|
+
const currentScore = this.getScore(email);
|
|
61
|
+
const newScore = Math.min(
|
|
62
|
+
this.#config.maxScore,
|
|
63
|
+
currentScore + this.#config.successReward
|
|
64
|
+
);
|
|
65
|
+
this.#scores.set(email, {
|
|
66
|
+
score: newScore,
|
|
67
|
+
lastUpdated: Date.now(),
|
|
68
|
+
consecutiveFailures: 0 // Reset on success
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Record a rate limit for an account
|
|
74
|
+
* @param {string} email - Account email
|
|
75
|
+
*/
|
|
76
|
+
recordRateLimit(email) {
|
|
77
|
+
const record = this.#scores.get(email);
|
|
78
|
+
const currentScore = this.getScore(email);
|
|
79
|
+
const newScore = Math.max(
|
|
80
|
+
0,
|
|
81
|
+
currentScore + this.#config.rateLimitPenalty
|
|
82
|
+
);
|
|
83
|
+
this.#scores.set(email, {
|
|
84
|
+
score: newScore,
|
|
85
|
+
lastUpdated: Date.now(),
|
|
86
|
+
consecutiveFailures: (record?.consecutiveFailures ?? 0) + 1
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Record a failure for an account
|
|
92
|
+
* @param {string} email - Account email
|
|
93
|
+
*/
|
|
94
|
+
recordFailure(email) {
|
|
95
|
+
const record = this.#scores.get(email);
|
|
96
|
+
const currentScore = this.getScore(email);
|
|
97
|
+
const newScore = Math.max(
|
|
98
|
+
0,
|
|
99
|
+
currentScore + this.#config.failurePenalty
|
|
100
|
+
);
|
|
101
|
+
this.#scores.set(email, {
|
|
102
|
+
score: newScore,
|
|
103
|
+
lastUpdated: Date.now(),
|
|
104
|
+
consecutiveFailures: (record?.consecutiveFailures ?? 0) + 1
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if an account is usable based on health score
|
|
110
|
+
* @param {string} email - Account email
|
|
111
|
+
* @returns {boolean} True if account health score is above minimum threshold
|
|
112
|
+
*/
|
|
113
|
+
isUsable(email) {
|
|
114
|
+
return this.getScore(email) >= this.#config.minUsable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the minimum usable score threshold
|
|
119
|
+
* @returns {number} Minimum score for an account to be usable
|
|
120
|
+
*/
|
|
121
|
+
getMinUsable() {
|
|
122
|
+
return this.#config.minUsable;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the maximum score cap
|
|
127
|
+
* @returns {number} Maximum health score
|
|
128
|
+
*/
|
|
129
|
+
getMaxScore() {
|
|
130
|
+
return this.#config.maxScore;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reset the score for an account (e.g., after re-authentication)
|
|
135
|
+
* @param {string} email - Account email
|
|
136
|
+
*/
|
|
137
|
+
reset(email) {
|
|
138
|
+
this.#scores.set(email, {
|
|
139
|
+
score: this.#config.initial,
|
|
140
|
+
lastUpdated: Date.now(),
|
|
141
|
+
consecutiveFailures: 0
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get the consecutive failure count for an account
|
|
147
|
+
* @param {string} email - Account email
|
|
148
|
+
* @returns {number} Number of consecutive failures
|
|
149
|
+
*/
|
|
150
|
+
getConsecutiveFailures(email) {
|
|
151
|
+
return this.#scores.get(email)?.consecutiveFailures ?? 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Clear all tracked scores
|
|
156
|
+
*/
|
|
157
|
+
clear() {
|
|
158
|
+
this.#scores.clear();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default HealthTracker;
|