antigravity-claude-proxy 2.3.0 → 2.3.2
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/package.json +1 -1
- package/src/account-manager/strategies/hybrid-strategy.js +49 -7
- package/src/account-manager/strategies/trackers/index.js +1 -0
- package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
- package/src/config.js +13 -1
- package/src/server.js +5 -0
- package/src/webui/index.js +19 -3
package/package.json
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hybrid Strategy
|
|
3
3
|
*
|
|
4
|
-
* Smart selection based on health score, token bucket, and LRU freshness.
|
|
4
|
+
* Smart selection based on health score, token bucket, quota, and LRU freshness.
|
|
5
5
|
* Combines multiple signals for optimal account distribution.
|
|
6
6
|
*
|
|
7
7
|
* Scoring formula:
|
|
8
|
-
* score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (LRU × 0.1)
|
|
8
|
+
* score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 3) + (LRU × 0.1)
|
|
9
9
|
*
|
|
10
10
|
* Filters accounts that are:
|
|
11
11
|
* - Not rate-limited
|
|
12
12
|
* - Not invalid or disabled
|
|
13
13
|
* - Health score >= minUsable
|
|
14
14
|
* - Has tokens available
|
|
15
|
+
* - Quota not critically low (< 5%)
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { BaseStrategy } from './base-strategy.js';
|
|
18
|
-
import { HealthTracker, TokenBucketTracker } from './trackers/index.js';
|
|
19
|
+
import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js';
|
|
19
20
|
import { logger } from '../../utils/logger.js';
|
|
20
21
|
|
|
21
22
|
// Default weights for scoring
|
|
22
23
|
const DEFAULT_WEIGHTS = {
|
|
23
24
|
health: 2,
|
|
24
25
|
tokens: 5,
|
|
26
|
+
quota: 3,
|
|
25
27
|
lru: 0.1
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export class HybridStrategy extends BaseStrategy {
|
|
29
31
|
#healthTracker;
|
|
30
32
|
#tokenBucketTracker;
|
|
33
|
+
#quotaTracker;
|
|
31
34
|
#weights;
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -35,12 +38,14 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
35
38
|
* @param {Object} config - Strategy configuration
|
|
36
39
|
* @param {Object} [config.healthScore] - Health tracker configuration
|
|
37
40
|
* @param {Object} [config.tokenBucket] - Token bucket configuration
|
|
41
|
+
* @param {Object} [config.quota] - Quota tracker configuration
|
|
38
42
|
* @param {Object} [config.weights] - Scoring weights
|
|
39
43
|
*/
|
|
40
44
|
constructor(config = {}) {
|
|
41
45
|
super(config);
|
|
42
46
|
this.#healthTracker = new HealthTracker(config.healthScore || {});
|
|
43
47
|
this.#tokenBucketTracker = new TokenBucketTracker(config.tokenBucket || {});
|
|
48
|
+
this.#quotaTracker = new QuotaTracker(config.quota || {});
|
|
44
49
|
this.#weights = { ...DEFAULT_WEIGHTS, ...config.weights };
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -71,7 +76,7 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
71
76
|
const scored = candidates.map(({ account, index }) => ({
|
|
72
77
|
account,
|
|
73
78
|
index,
|
|
74
|
-
score: this.#calculateScore(account)
|
|
79
|
+
score: this.#calculateScore(account, modelId)
|
|
75
80
|
}));
|
|
76
81
|
|
|
77
82
|
scored.sort((a, b) => b.score - a.score);
|
|
@@ -126,7 +131,7 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
126
131
|
* @private
|
|
127
132
|
*/
|
|
128
133
|
#getCandidates(accounts, modelId) {
|
|
129
|
-
|
|
134
|
+
const candidates = accounts
|
|
130
135
|
.map((account, index) => ({ account, index }))
|
|
131
136
|
.filter(({ account }) => {
|
|
132
137
|
// Basic usability check
|
|
@@ -144,15 +149,40 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
144
149
|
return false;
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
// Quota availability check (exclude critically low quota)
|
|
153
|
+
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
|
|
154
|
+
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId}`);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
return true;
|
|
148
159
|
});
|
|
160
|
+
|
|
161
|
+
// If no candidates after quota filter, fall back to all usable accounts
|
|
162
|
+
// (better to use critical quota than fail entirely)
|
|
163
|
+
if (candidates.length === 0) {
|
|
164
|
+
const fallback = accounts
|
|
165
|
+
.map((account, index) => ({ account, index }))
|
|
166
|
+
.filter(({ account }) => {
|
|
167
|
+
if (!this.isAccountUsable(account, modelId)) return false;
|
|
168
|
+
if (!this.#healthTracker.isUsable(account.email)) return false;
|
|
169
|
+
if (!this.#tokenBucketTracker.hasTokens(account.email)) return false;
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
if (fallback.length > 0) {
|
|
173
|
+
logger.warn('[HybridStrategy] All accounts have critical quota, using fallback');
|
|
174
|
+
return fallback;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return candidates;
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
/**
|
|
152
182
|
* Calculate the combined score for an account
|
|
153
183
|
* @private
|
|
154
184
|
*/
|
|
155
|
-
#calculateScore(account) {
|
|
185
|
+
#calculateScore(account, modelId) {
|
|
156
186
|
const email = account.email;
|
|
157
187
|
|
|
158
188
|
// Health component (0-100 scaled by weight)
|
|
@@ -165,6 +195,10 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
165
195
|
const tokenRatio = tokens / maxTokens;
|
|
166
196
|
const tokenComponent = (tokenRatio * 100) * this.#weights.tokens;
|
|
167
197
|
|
|
198
|
+
// Quota component (0-100 scaled by weight)
|
|
199
|
+
const quotaScore = this.#quotaTracker.getScore(account, modelId);
|
|
200
|
+
const quotaComponent = quotaScore * this.#weights.quota;
|
|
201
|
+
|
|
168
202
|
// LRU component (older = higher score)
|
|
169
203
|
// Use time since last use, capped at 1 hour for scoring
|
|
170
204
|
const lastUsed = account.lastUsed || 0;
|
|
@@ -172,7 +206,7 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
172
206
|
const lruMinutes = timeSinceLastUse / 60000;
|
|
173
207
|
const lruComponent = lruMinutes * this.#weights.lru;
|
|
174
208
|
|
|
175
|
-
return healthComponent + tokenComponent + lruComponent;
|
|
209
|
+
return healthComponent + tokenComponent + quotaComponent + lruComponent;
|
|
176
210
|
}
|
|
177
211
|
|
|
178
212
|
/**
|
|
@@ -190,6 +224,14 @@ export class HybridStrategy extends BaseStrategy {
|
|
|
190
224
|
getTokenBucketTracker() {
|
|
191
225
|
return this.#tokenBucketTracker;
|
|
192
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get the quota tracker (for testing/debugging)
|
|
230
|
+
* @returns {QuotaTracker} The quota tracker instance
|
|
231
|
+
*/
|
|
232
|
+
getQuotaTracker() {
|
|
233
|
+
return this.#quotaTracker;
|
|
234
|
+
}
|
|
193
235
|
}
|
|
194
236
|
|
|
195
237
|
export default HybridStrategy;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Tracker
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-account quota levels to prioritize accounts with available quota.
|
|
5
|
+
* Uses quota data from account.quota.models[modelId].remainingFraction.
|
|
6
|
+
* Accounts below critical threshold are excluded from selection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Default configuration
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
lowThreshold: 0.10, // 10% - reduce score
|
|
12
|
+
criticalThreshold: 0.05, // 5% - exclude from candidates
|
|
13
|
+
staleMs: 300000, // 5 min - max age of quota data to trust
|
|
14
|
+
unknownScore: 50 // Score for accounts with unknown quota
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class QuotaTracker {
|
|
18
|
+
#config;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new QuotaTracker
|
|
22
|
+
* @param {Object} config - Quota tracker configuration
|
|
23
|
+
*/
|
|
24
|
+
constructor(config = {}) {
|
|
25
|
+
this.#config = { ...DEFAULT_CONFIG, ...config };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the quota fraction for an account and model
|
|
30
|
+
* @param {Object} account - Account object
|
|
31
|
+
* @param {string} modelId - Model ID to check
|
|
32
|
+
* @returns {number|null} Remaining fraction (0-1) or null if unknown
|
|
33
|
+
*/
|
|
34
|
+
getQuotaFraction(account, modelId) {
|
|
35
|
+
if (!account?.quota?.models?.[modelId]) return null;
|
|
36
|
+
const fraction = account.quota.models[modelId].remainingFraction;
|
|
37
|
+
return typeof fraction === 'number' ? fraction : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if quota data is fresh enough to be trusted
|
|
42
|
+
* @param {Object} account - Account object
|
|
43
|
+
* @returns {boolean} True if quota data is fresh
|
|
44
|
+
*/
|
|
45
|
+
isQuotaFresh(account) {
|
|
46
|
+
if (!account?.quota?.lastChecked) return false;
|
|
47
|
+
return (Date.now() - account.quota.lastChecked) < this.#config.staleMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if an account has critically low quota for a model
|
|
52
|
+
* @param {Object} account - Account object
|
|
53
|
+
* @param {string} modelId - Model ID to check
|
|
54
|
+
* @returns {boolean} True if quota is at or below critical threshold
|
|
55
|
+
*/
|
|
56
|
+
isQuotaCritical(account, modelId) {
|
|
57
|
+
const fraction = this.getQuotaFraction(account, modelId);
|
|
58
|
+
// Unknown quota = not critical (assume OK)
|
|
59
|
+
if (fraction === null) return false;
|
|
60
|
+
// Only apply critical check if data is fresh
|
|
61
|
+
if (!this.isQuotaFresh(account)) return false;
|
|
62
|
+
return fraction <= this.#config.criticalThreshold;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if an account has low (but not critical) quota for a model
|
|
67
|
+
* @param {Object} account - Account object
|
|
68
|
+
* @param {string} modelId - Model ID to check
|
|
69
|
+
* @returns {boolean} True if quota is below low threshold but above critical
|
|
70
|
+
*/
|
|
71
|
+
isQuotaLow(account, modelId) {
|
|
72
|
+
const fraction = this.getQuotaFraction(account, modelId);
|
|
73
|
+
if (fraction === null) return false;
|
|
74
|
+
return fraction <= this.#config.lowThreshold && fraction > this.#config.criticalThreshold;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get a score (0-100) for an account based on quota
|
|
79
|
+
* Higher score = more quota available
|
|
80
|
+
* @param {Object} account - Account object
|
|
81
|
+
* @param {string} modelId - Model ID to check
|
|
82
|
+
* @returns {number} Score from 0-100
|
|
83
|
+
*/
|
|
84
|
+
getScore(account, modelId) {
|
|
85
|
+
const fraction = this.getQuotaFraction(account, modelId);
|
|
86
|
+
|
|
87
|
+
// Unknown quota = middle score
|
|
88
|
+
if (fraction === null) {
|
|
89
|
+
return this.#config.unknownScore;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Convert fraction (0-1) to score (0-100)
|
|
93
|
+
let score = fraction * 100;
|
|
94
|
+
|
|
95
|
+
// Apply small penalty for stale data (reduce confidence)
|
|
96
|
+
if (!this.isQuotaFresh(account)) {
|
|
97
|
+
score *= 0.9; // 10% penalty for stale data
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return score;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get the critical threshold
|
|
105
|
+
* @returns {number} Critical threshold (0-1)
|
|
106
|
+
*/
|
|
107
|
+
getCriticalThreshold() {
|
|
108
|
+
return this.#config.criticalThreshold;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the low threshold
|
|
113
|
+
* @returns {number} Low threshold (0-1)
|
|
114
|
+
*/
|
|
115
|
+
getLowThreshold() {
|
|
116
|
+
return this.#config.lowThreshold;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default QuotaTracker;
|
package/src/config.js
CHANGED
|
@@ -34,6 +34,11 @@ const DEFAULT_CONFIG = {
|
|
|
34
34
|
maxTokens: 50, // Maximum token capacity
|
|
35
35
|
tokensPerMinute: 6, // Regeneration rate
|
|
36
36
|
initialTokens: 50 // Starting tokens
|
|
37
|
+
},
|
|
38
|
+
quota: {
|
|
39
|
+
lowThreshold: 0.10, // 10% - reduce score
|
|
40
|
+
criticalThreshold: 0.05, // 5% - exclude from candidates
|
|
41
|
+
staleMs: 300000 // 5 min - max age of quota data to trust
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
};
|
|
@@ -88,7 +93,14 @@ function loadConfig() {
|
|
|
88
93
|
loadConfig();
|
|
89
94
|
|
|
90
95
|
export function getPublicConfig() {
|
|
91
|
-
|
|
96
|
+
// Create a deep copy and redact sensitive fields
|
|
97
|
+
const publicConfig = JSON.parse(JSON.stringify(config));
|
|
98
|
+
|
|
99
|
+
// Redact sensitive values
|
|
100
|
+
if (publicConfig.webuiPassword) publicConfig.webuiPassword = '********';
|
|
101
|
+
if (publicConfig.apiKey) publicConfig.apiKey = '********';
|
|
102
|
+
|
|
103
|
+
return publicConfig;
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
export function saveConfig(updates) {
|
package/src/server.js
CHANGED
|
@@ -734,6 +734,11 @@ app.post('/v1/messages', async (req, res) => {
|
|
|
734
734
|
});
|
|
735
735
|
}
|
|
736
736
|
|
|
737
|
+
// Filter out "count" requests (often automated background checks)
|
|
738
|
+
if (messages.length === 1 && messages[0].content === 'count') {
|
|
739
|
+
return res.json({});
|
|
740
|
+
}
|
|
741
|
+
|
|
737
742
|
// Build the request object
|
|
738
743
|
const request = {
|
|
739
744
|
model: modelId,
|
package/src/webui/index.js
CHANGED
|
@@ -127,8 +127,9 @@ function createAuthMiddleware() {
|
|
|
127
127
|
|
|
128
128
|
// Determine if this path should be protected
|
|
129
129
|
const isApiRoute = req.path.startsWith('/api/');
|
|
130
|
-
const
|
|
131
|
-
const
|
|
130
|
+
const isAuthUrl = req.path === '/api/auth/url';
|
|
131
|
+
const isConfigGet = req.path === '/api/config' && req.method === 'GET';
|
|
132
|
+
const isProtected = (isApiRoute && !isAuthUrl && !isConfigGet) || req.path === '/account-limits' || req.path === '/health';
|
|
132
133
|
|
|
133
134
|
if (isProtected) {
|
|
134
135
|
const providedPassword = req.headers['x-webui-password'] || req.query.password;
|
|
@@ -287,7 +288,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
|
|
287
288
|
*/
|
|
288
289
|
app.post('/api/config', (req, res) => {
|
|
289
290
|
try {
|
|
290
|
-
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection } = req.body;
|
|
291
|
+
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, capacityRetryDelayMs, maxCapacityRetries } = req.body;
|
|
291
292
|
|
|
292
293
|
// Only allow updating specific fields (security)
|
|
293
294
|
const updates = {};
|
|
@@ -316,6 +317,21 @@ export function mountWebUI(app, dirname, accountManager) {
|
|
|
316
317
|
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
|
|
317
318
|
updates.maxAccounts = maxAccounts;
|
|
318
319
|
}
|
|
320
|
+
if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) {
|
|
321
|
+
updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs;
|
|
322
|
+
}
|
|
323
|
+
if (typeof maxConsecutiveFailures === 'number' && maxConsecutiveFailures >= 1 && maxConsecutiveFailures <= 10) {
|
|
324
|
+
updates.maxConsecutiveFailures = maxConsecutiveFailures;
|
|
325
|
+
}
|
|
326
|
+
if (typeof extendedCooldownMs === 'number' && extendedCooldownMs >= 10000 && extendedCooldownMs <= 300000) {
|
|
327
|
+
updates.extendedCooldownMs = extendedCooldownMs;
|
|
328
|
+
}
|
|
329
|
+
if (typeof capacityRetryDelayMs === 'number' && capacityRetryDelayMs >= 500 && capacityRetryDelayMs <= 10000) {
|
|
330
|
+
updates.capacityRetryDelayMs = capacityRetryDelayMs;
|
|
331
|
+
}
|
|
332
|
+
if (typeof maxCapacityRetries === 'number' && maxCapacityRetries >= 1 && maxCapacityRetries <= 10) {
|
|
333
|
+
updates.maxCapacityRetries = maxCapacityRetries;
|
|
334
|
+
}
|
|
319
335
|
// Account selection strategy validation
|
|
320
336
|
if (accountSelection && typeof accountSelection === 'object') {
|
|
321
337
|
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|