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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antigravity-claude-proxy",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
- return accounts
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;
@@ -6,3 +6,4 @@
6
6
 
7
7
  export { HealthTracker } from './health-tracker.js';
8
8
  export { TokenBucketTracker } from './token-bucket-tracker.js';
9
+ export { QuotaTracker } from './quota-tracker.js';
@@ -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
- return { ...config };
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,
@@ -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 isException = req.path === '/api/auth/url' || req.path === '/api/config';
131
- const isProtected = (isApiRoute && !isException) || req.path === '/account-limits' || req.path === '/health';
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'];