antigravity-claude-proxy 2.7.1 → 2.7.3

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.
@@ -156,15 +156,17 @@ export function markRateLimited(accounts, email, resetMs = null, modelId) {
156
156
  * @param {Array} accounts - Array of account objects
157
157
  * @param {string} email - Email of the account to mark
158
158
  * @param {string} reason - Reason for marking as invalid
159
+ * @param {string|null} verifyUrl - Optional verification URL (for 403 VALIDATION_REQUIRED)
159
160
  * @returns {boolean} True if account was found and marked
160
161
  */
161
- export function markInvalid(accounts, email, reason = 'Unknown error') {
162
+ export function markInvalid(accounts, email, reason = 'Unknown error', verifyUrl = null) {
162
163
  const account = accounts.find(a => a.email === email);
163
164
  if (!account) return false;
164
165
 
165
166
  account.isInvalid = true;
166
167
  account.invalidReason = reason;
167
168
  account.invalidAt = Date.now();
169
+ account.verifyUrl = verifyUrl || null;
168
170
 
169
171
  logger.error(
170
172
  `[AccountManager] ⚠ Account INVALID: ${email}`
@@ -172,6 +174,11 @@ export function markInvalid(accounts, email, reason = 'Unknown error') {
172
174
  logger.error(
173
175
  `[AccountManager] Reason: ${reason}`
174
176
  );
177
+ if (verifyUrl) {
178
+ logger.error(
179
+ `[AccountManager] Verification URL: ${verifyUrl}`
180
+ );
181
+ }
175
182
  logger.error(
176
183
  `[AccountManager] Run 'npm run accounts' to re-authenticate this account`
177
184
  );
@@ -179,6 +186,25 @@ export function markInvalid(accounts, email, reason = 'Unknown error') {
179
186
  return true;
180
187
  }
181
188
 
189
+ /**
190
+ * Clear invalid status for an account (after user completes verification)
191
+ *
192
+ * @param {Array} accounts - Array of account objects
193
+ * @param {string} email - Email of the account to clear
194
+ * @returns {boolean} True if account was found and cleared
195
+ */
196
+ export function clearInvalid(accounts, email) {
197
+ const account = accounts.find(a => a.email === email);
198
+ if (!account) return false;
199
+
200
+ account.isInvalid = false;
201
+ account.invalidReason = null;
202
+ account.verifyUrl = null;
203
+
204
+ logger.info(`[AccountManager] ✓ Account re-enabled: ${email}`);
205
+ return true;
206
+ }
207
+
182
208
  /**
183
209
  * Get the minimum wait time until any account becomes available for a model
184
210
  *
@@ -10,7 +10,6 @@ import { dirname } from 'path';
10
10
  import { ACCOUNT_CONFIG_PATH } from '../constants.js';
11
11
  import { getAuthStatus } from '../auth/database.js';
12
12
  import { logger } from '../utils/logger.js';
13
- import { generateFingerprint, updateFingerprintVersion } from '../utils/fingerprint.js';
14
13
 
15
14
  /**
16
15
  * Load accounts from the config file
@@ -29,21 +28,18 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
29
28
  ...acc,
30
29
  lastUsed: acc.lastUsed || null,
31
30
  enabled: acc.enabled !== false, // Default to true if not specified
32
- // Reset invalid flag on startup - give accounts a fresh chance to refresh
33
- isInvalid: false,
34
- invalidReason: null,
31
+ // Reset invalid flag on startup - give accounts a fresh chance
32
+ // EXCEPT accounts with a verifyUrl — those need user intervention
33
+ isInvalid: acc.verifyUrl ? (acc.isInvalid || false) : false,
34
+ invalidReason: acc.verifyUrl ? (acc.invalidReason || null) : null,
35
+ verifyUrl: acc.verifyUrl || null,
35
36
  modelRateLimits: acc.modelRateLimits || {},
36
37
  // New fields for subscription and quota tracking
37
38
  subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
38
39
  quota: acc.quota || { models: {}, lastChecked: null },
39
40
  // Quota threshold settings (per-account and per-model overrides)
40
41
  quotaThreshold: acc.quotaThreshold, // undefined means use global
41
- modelQuotaThresholds: acc.modelQuotaThresholds || {},
42
- // Fingerprint management
43
- fingerprint: acc.fingerprint
44
- ? updateFingerprintVersion(acc.fingerprint)
45
- : generateFingerprint(),
46
- fingerprintHistory: acc.fingerprintHistory || []
42
+ modelQuotaThresholds: acc.modelQuotaThresholds || {}
47
43
  }));
48
44
 
49
45
  const settings = config.settings || {};
@@ -82,8 +78,7 @@ export function loadDefaultAccount(dbPath) {
82
78
  email: authData.email || 'default@antigravity',
83
79
  source: 'database',
84
80
  lastUsed: null,
85
- modelRateLimits: {},
86
- fingerprint: generateFingerprint()
81
+ modelRateLimits: {}
87
82
  };
88
83
 
89
84
  const tokenCache = new Map();
@@ -129,6 +124,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
129
124
  addedAt: acc.addedAt || undefined,
130
125
  isInvalid: acc.isInvalid || false,
131
126
  invalidReason: acc.invalidReason || null,
127
+ verifyUrl: acc.verifyUrl || null,
132
128
  modelRateLimits: acc.modelRateLimits || {},
133
129
  lastUsed: acc.lastUsed,
134
130
  // Persist subscription and quota data
@@ -136,10 +132,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
136
132
  quota: acc.quota || { models: {}, lastChecked: null },
137
133
  // Persist quota threshold settings
138
134
  quotaThreshold: acc.quotaThreshold, // undefined omitted from JSON
139
- modelQuotaThresholds: Object.keys(acc.modelQuotaThresholds || {}).length > 0 ? acc.modelQuotaThresholds : undefined,
140
- // Persist fingerprint data
141
- fingerprint: acc.fingerprint,
142
- fingerprintHistory: acc.fingerprintHistory && acc.fingerprintHistory.length > 0 ? acc.fingerprintHistory : undefined
135
+ modelQuotaThresholds: Object.keys(acc.modelQuotaThresholds || {}).length > 0 ? acc.modelQuotaThresholds : undefined
143
136
  })),
144
137
  settings: settings,
145
138
  activeIndex: activeIndex
package/src/auth/oauth.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  OAUTH_REDIRECT_URI
17
17
  } from '../constants.js';
18
18
  import { logger } from '../utils/logger.js';
19
+ import { throttledFetch } from '../utils/helpers.js';
19
20
  import { onboardUser, getDefaultTierId } from '../account-manager/onboarding.js';
20
21
 
21
22
  /**
@@ -354,7 +355,7 @@ Option 4: Exclude port from reservation (run as Administrator)
354
355
  * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} OAuth tokens
355
356
  */
356
357
  export async function exchangeCode(code, verifier) {
357
- const response = await fetch(OAUTH_CONFIG.tokenUrl, {
358
+ const response = await throttledFetch(OAUTH_CONFIG.tokenUrl, {
358
359
  method: 'POST',
359
360
  headers: {
360
361
  'Content-Type': 'application/x-www-form-urlencoded'
@@ -402,7 +403,7 @@ export async function refreshAccessToken(compositeRefresh) {
402
403
  // Parse the composite refresh token to extract the actual OAuth token
403
404
  const parts = parseRefreshParts(compositeRefresh);
404
405
 
405
- const response = await fetch(OAUTH_CONFIG.tokenUrl, {
406
+ const response = await throttledFetch(OAUTH_CONFIG.tokenUrl, {
406
407
  method: 'POST',
407
408
  headers: {
408
409
  'Content-Type': 'application/x-www-form-urlencoded'
@@ -434,7 +435,7 @@ export async function refreshAccessToken(compositeRefresh) {
434
435
  * @returns {Promise<string>} User's email address
435
436
  */
436
437
  export async function getUserEmail(accessToken) {
437
- const response = await fetch(OAUTH_CONFIG.userInfoUrl, {
438
+ const response = await throttledFetch(OAUTH_CONFIG.userInfoUrl, {
438
439
  headers: {
439
440
  'Authorization': `Bearer ${accessToken}`
440
441
  }
@@ -461,7 +462,7 @@ export async function discoverProjectId(accessToken) {
461
462
 
462
463
  for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
463
464
  try {
464
- const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
465
+ const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, {
465
466
  method: 'POST',
466
467
  headers: {
467
468
  'Authorization': `Bearer ${accessToken}`,
@@ -50,7 +50,7 @@ function isServerRunning() {
50
50
  resolve(false);
51
51
  });
52
52
 
53
- socket.on('error', () => {
53
+ socket.on('error', (err) => {
54
54
  socket.destroy();
55
55
  resolve(false); // Port free
56
56
  });
@@ -143,9 +143,7 @@ function saveAccounts(accounts, settings = {}) {
143
143
  projectId: acc.projectId,
144
144
  addedAt: acc.addedAt || new Date().toISOString(),
145
145
  lastUsed: acc.lastUsed || null,
146
- modelRateLimits: acc.modelRateLimits || {},
147
- fingerprint: acc.fingerprint,
148
- fingerprintHistory: acc.fingerprintHistory
146
+ modelRateLimits: acc.modelRateLimits || {}
149
147
  })),
150
148
  settings: {
151
149
  maxRetries: 5,
@@ -19,8 +19,8 @@ import {
19
19
  isThinkingModel
20
20
  } from '../constants.js';
21
21
  import { convertGoogleToAnthropic } from '../format/index.js';
22
- import { isRateLimitError, isAuthError } from '../errors.js';
23
- import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
22
+ import { isRateLimitError, isAuthError, isAccountForbiddenError, AccountForbiddenError } from '../errors.js';
23
+ import { formatDuration, sleep, isNetworkError, throttledFetch } from '../utils/helpers.js';
24
24
  import { logger } from '../utils/logger.js';
25
25
  import { parseResetTime } from './rate-limit-parser.js';
26
26
  import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
@@ -31,6 +31,8 @@ import {
31
31
  clearRateLimitState,
32
32
  isPermanentAuthFailure,
33
33
  isModelCapacityExhausted,
34
+ isValidationRequired,
35
+ extractVerificationUrl,
34
36
  calculateSmartBackoff
35
37
  } from './rate-limit-state.js';
36
38
 
@@ -64,6 +66,16 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
64
66
 
65
67
  // If no accounts available, check if we should wait or throw error
66
68
  if (availableAccounts.length === 0) {
69
+ // All accounts invalid? Fail immediately — they need user intervention (WebUI FIX button)
70
+ // Invalid accounts won't self-recover, so waiting would be an infinite loop
71
+ if (accountManager.isAllAccountsInvalid()) {
72
+ const invalidAccounts = accountManager.getInvalidAccounts();
73
+ const reasons = [...new Set(invalidAccounts.map(a => a.invalidReason).filter(Boolean))];
74
+ throw new Error(
75
+ `All accounts are invalid: ${reasons.join('; ') || 'unknown reason'}. Visit the WebUI to fix them.`
76
+ );
77
+ }
78
+
67
79
  if (accountManager.isAllRateLimited(model)) {
68
80
  const minWaitMs = accountManager.getMinWaitTimeMs(model);
69
81
  const resetTime = new Date(Date.now() + minWaitMs).toISOString();
@@ -143,9 +155,9 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
143
155
  ? `${endpoint}/v1internal:streamGenerateContent?alt=sse`
144
156
  : `${endpoint}/v1internal:generateContent`;
145
157
 
146
- const response = await fetch(url, {
158
+ const response = await throttledFetch(url, {
147
159
  method: 'POST',
148
- headers: buildHeaders(token, model, isThinking ? 'text/event-stream' : 'application/json', account.fingerprint),
160
+ headers: buildHeaders(token, model, isThinking ? 'text/event-stream' : 'application/json'),
149
161
  body: JSON.stringify(payload)
150
162
  });
151
163
 
@@ -273,6 +285,16 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
273
285
  throw new Error(`invalid_request_error: ${errorText}`);
274
286
  }
275
287
 
288
+ // 403 with VALIDATION_REQUIRED or PERMISSION_DENIED is an account-level error
289
+ // The account needs validation (captcha, terms, etc.) - trying different endpoints won't help
290
+ // Mark account as invalid (requires user intervention) and rotate (fixes #248)
291
+ if (response.status === 403 && isValidationRequired(errorText)) {
292
+ const verifyUrl = extractVerificationUrl(errorText);
293
+ logger.warn(`[CloudCode] 403 VALIDATION_REQUIRED/PERMISSION_DENIED for ${account.email}, marking invalid and rotating account...`);
294
+ accountManager.markInvalid(account.email, 'Account requires verification', verifyUrl);
295
+ throw new AccountForbiddenError(errorText, account.email);
296
+ }
297
+
276
298
  lastError = new Error(`API error ${response.status}: ${errorText}`);
277
299
  // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
278
300
  if (response.status === 403 || response.status === 404) {
@@ -307,6 +329,10 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
307
329
  if (isRateLimitError(endpointError)) {
308
330
  throw endpointError; // Re-throw to trigger account switch
309
331
  }
332
+ // 403 account-level errors - re-throw to trigger account rotation
333
+ if (isAccountForbiddenError(endpointError)) {
334
+ throw endpointError;
335
+ }
310
336
  // 400 errors are client errors - re-throw immediately, don't retry
311
337
  if (endpointError.message?.includes('400')) {
312
338
  throw endpointError;
@@ -347,6 +373,13 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
347
373
  logger.warn(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`);
348
374
  continue;
349
375
  }
376
+ if (isAccountForbiddenError(error)) {
377
+ // 403 VALIDATION_REQUIRED / PERMISSION_DENIED - account-level error
378
+ // Already marked with cooldown, notify strategy and rotate to next account
379
+ accountManager.notifyFailure(account, model);
380
+ logger.warn(`[CloudCode] Account ${account.email} forbidden (403 VALIDATION_REQUIRED), trying next...`);
381
+ continue;
382
+ }
350
383
  // Handle 5xx errors
351
384
  if (error.message.includes('API error 5') || error.message.includes('500') || error.message.includes('503')) {
352
385
  accountManager.notifyFailure(account, model);
@@ -14,6 +14,7 @@ import {
14
14
  MODEL_VALIDATION_CACHE_TTL_MS
15
15
  } from '../constants.js';
16
16
  import { logger } from '../utils/logger.js';
17
+ import { throttledFetch } from '../utils/helpers.js';
17
18
 
18
19
  // Model validation cache
19
20
  const modelCache = {
@@ -86,7 +87,7 @@ export async function fetchAvailableModels(token, projectId = null) {
86
87
  for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
87
88
  try {
88
89
  const url = `${endpoint}/v1internal:fetchAvailableModels`;
89
- const response = await fetch(url, {
90
+ const response = await throttledFetch(url, {
90
91
  method: 'POST',
91
92
  headers,
92
93
  body: JSON.stringify(body)
@@ -178,7 +179,7 @@ export async function getSubscriptionTier(token) {
178
179
  for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
179
180
  try {
180
181
  const url = `${endpoint}/v1internal:loadCodeAssist`;
181
- const response = await fetch(url, {
182
+ const response = await throttledFetch(url, {
182
183
  method: 'POST',
183
184
  headers,
184
185
  body: JSON.stringify({
@@ -98,6 +98,47 @@ export function isPermanentAuthFailure(errorText) {
98
98
  lower.includes('credentials are invalid');
99
99
  }
100
100
 
101
+ /**
102
+ * Detect if 403 error is due to VALIDATION_REQUIRED or PERMISSION_DENIED.
103
+ * These are account-level errors that should trigger account rotation,
104
+ * not just endpoint rotation. The account needs validation (e.g., captcha,
105
+ * terms acceptance) which cannot be resolved by trying different endpoints.
106
+ * @param {string} errorText - Error message from API
107
+ * @returns {boolean} True if validation/permission error requiring account rotation
108
+ */
109
+ export function isValidationRequired(errorText) {
110
+ const lower = (errorText || '').toLowerCase();
111
+ return lower.includes('validation_required') ||
112
+ lower.includes('account_disabled') ||
113
+ lower.includes('user_disabled');
114
+ }
115
+
116
+ /**
117
+ * Extract the Google verification URL from an error message.
118
+ * The 403 VALIDATION_REQUIRED error contains a URL the user must visit.
119
+ * @param {string} errorText - Error message from the API
120
+ * @returns {string|null} The verification URL, or null if not found
121
+ */
122
+ export function extractVerificationUrl(errorText) {
123
+ if (!errorText) return null;
124
+ // Try structured JSON first — the 403 response often has details[].metadata.validation_url
125
+ try {
126
+ const parsed = JSON.parse(errorText);
127
+ const details = parsed?.error?.details || [];
128
+ for (const detail of details) {
129
+ if (detail?.metadata?.validation_url) {
130
+ return detail.metadata.validation_url;
131
+ }
132
+ }
133
+ } catch {
134
+ // Not valid JSON or no structured field — fall through to regex
135
+ }
136
+ // Fallback: regex match for verification URL in unstructured text
137
+ const raw = errorText.match(/https:\/\/accounts\.google\.com\/signin\/continue\?[^\s"\\]+/);
138
+ if (!raw) return null;
139
+ return raw[0].replace(/[,.)}>\]]+$/, '');
140
+ }
141
+
101
142
  /**
102
143
  * Detect if 429 error is due to model capacity (not user quota).
103
144
  * Capacity issues should retry on same account with shorter delay.
@@ -13,7 +13,6 @@ import {
13
13
  } from '../constants.js';
14
14
  import { convertAnthropicToGoogle } from '../format/index.js';
15
15
  import { deriveSessionId } from './session-manager.js';
16
- import { buildFingerprintHeaders } from '../utils/fingerprint.js';
17
16
 
18
17
  /**
19
18
  * Build the wrapped request body for Cloud Code API
@@ -70,17 +69,13 @@ export function buildCloudCodeRequest(anthropicRequest, projectId) {
70
69
  * @param {string} token - OAuth access token
71
70
  * @param {string} model - Model name
72
71
  * @param {string} accept - Accept header value (default: 'application/json')
73
- * @param {Object} [fingerprint] - Optional device fingerprint for header randomization
74
72
  * @returns {Object} Headers object
75
73
  */
76
- export function buildHeaders(token, model, accept = 'application/json', fingerprint = null) {
77
- const fingerprintHeaders = fingerprint ? buildFingerprintHeaders(fingerprint) : {};
78
-
74
+ export function buildHeaders(token, model, accept = 'application/json') {
79
75
  const headers = {
80
76
  'Authorization': `Bearer ${token}`,
81
77
  'Content-Type': 'application/json',
82
- ...ANTIGRAVITY_HEADERS,
83
- ...fingerprintHeaders
78
+ ...ANTIGRAVITY_HEADERS
84
79
  };
85
80
 
86
81
  const modelFamily = getModelFamily(model);
@@ -18,8 +18,8 @@ import {
18
18
  MAX_CAPACITY_RETRIES,
19
19
  BACKOFF_BY_ERROR_TYPE
20
20
  } from '../constants.js';
21
- import { isRateLimitError, isAuthError, isEmptyResponseError } from '../errors.js';
22
- import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
21
+ import { isRateLimitError, isAuthError, isEmptyResponseError, isAccountForbiddenError, AccountForbiddenError } from '../errors.js';
22
+ import { formatDuration, sleep, isNetworkError, throttledFetch } from '../utils/helpers.js';
23
23
  import { logger } from '../utils/logger.js';
24
24
  import { parseResetTime } from './rate-limit-parser.js';
25
25
  import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
@@ -30,6 +30,8 @@ import {
30
30
  clearRateLimitState,
31
31
  isPermanentAuthFailure,
32
32
  isModelCapacityExhausted,
33
+ isValidationRequired,
34
+ extractVerificationUrl,
33
35
  calculateSmartBackoff
34
36
  } from './rate-limit-state.js';
35
37
  import crypto from 'crypto';
@@ -63,6 +65,16 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
63
65
 
64
66
  // If no accounts available, check if we should wait or throw error
65
67
  if (availableAccounts.length === 0) {
68
+ // All accounts invalid? Fail immediately — they need user intervention (WebUI FIX button)
69
+ // Invalid accounts won't self-recover, so waiting would be an infinite loop
70
+ if (accountManager.isAllAccountsInvalid()) {
71
+ const invalidAccounts = accountManager.getInvalidAccounts();
72
+ const reasons = [...new Set(invalidAccounts.map(a => a.invalidReason).filter(Boolean))];
73
+ throw new Error(
74
+ `All accounts are invalid: ${reasons.join('; ') || 'unknown reason'}. Visit the WebUI to fix them.`
75
+ );
76
+ }
77
+
66
78
  if (accountManager.isAllRateLimited(model)) {
67
79
  const minWaitMs = accountManager.getMinWaitTimeMs(model);
68
80
  const resetTime = new Date(Date.now() + minWaitMs).toISOString();
@@ -141,9 +153,9 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
141
153
  try {
142
154
  const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
143
155
 
144
- const response = await fetch(url, {
156
+ const response = await throttledFetch(url, {
145
157
  method: 'POST',
146
- headers: buildHeaders(token, model, 'text/event-stream', account.fingerprint),
158
+ headers: buildHeaders(token, model, 'text/event-stream'),
147
159
  body: JSON.stringify(payload)
148
160
  });
149
161
 
@@ -268,6 +280,16 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
268
280
  throw new Error(`invalid_request_error: ${errorText}`);
269
281
  }
270
282
 
283
+ // 403 with VALIDATION_REQUIRED or PERMISSION_DENIED is an account-level error
284
+ // The account needs validation (captcha, terms, etc.) - trying different endpoints won't help
285
+ // Mark account as invalid (requires user intervention) and rotate (fixes #248)
286
+ if (response.status === 403 && isValidationRequired(errorText)) {
287
+ const verifyUrl = extractVerificationUrl(errorText);
288
+ logger.warn(`[CloudCode] 403 VALIDATION_REQUIRED/PERMISSION_DENIED for ${account.email}, marking invalid and rotating account...`);
289
+ accountManager.markInvalid(account.email, 'Account requires verification', verifyUrl);
290
+ throw new AccountForbiddenError(errorText, account.email);
291
+ }
292
+
271
293
  lastError = new Error(`API error ${response.status}: ${errorText}`);
272
294
 
273
295
  // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
@@ -312,9 +334,9 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
312
334
  await sleep(backoffMs);
313
335
 
314
336
  // Refetch the response
315
- currentResponse = await fetch(url, {
337
+ currentResponse = await throttledFetch(url, {
316
338
  method: 'POST',
317
- headers: buildHeaders(token, model, 'text/event-stream', account.fingerprint),
339
+ headers: buildHeaders(token, model, 'text/event-stream'),
318
340
  body: JSON.stringify(payload)
319
341
  });
320
342
 
@@ -345,9 +367,9 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
345
367
  if (currentResponse.status >= 500) {
346
368
  logger.warn(`[CloudCode] Retry got ${currentResponse.status}, will retry...`);
347
369
  await sleep(1000);
348
- currentResponse = await fetch(url, {
370
+ currentResponse = await throttledFetch(url, {
349
371
  method: 'POST',
350
- headers: buildHeaders(token, model, 'text/event-stream', account.fingerprint),
372
+ headers: buildHeaders(token, model, 'text/event-stream'),
351
373
  body: JSON.stringify(payload)
352
374
  });
353
375
  if (currentResponse.ok) {
@@ -367,6 +389,10 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
367
389
  if (isEmptyResponseError(endpointError)) {
368
390
  throw endpointError;
369
391
  }
392
+ // 403 account-level errors - re-throw to trigger account rotation
393
+ if (isAccountForbiddenError(endpointError)) {
394
+ throw endpointError;
395
+ }
370
396
  // 400 errors are client errors - re-throw immediately, don't retry
371
397
  if (endpointError.message?.includes('400')) {
372
398
  throw endpointError;
@@ -407,6 +433,13 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
407
433
  logger.warn(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`);
408
434
  continue;
409
435
  }
436
+ if (isAccountForbiddenError(error)) {
437
+ // 403 VALIDATION_REQUIRED / PERMISSION_DENIED - account-level error
438
+ // Already marked with cooldown, notify strategy and rotate to next account
439
+ accountManager.notifyFailure(account, model);
440
+ logger.warn(`[CloudCode] Account ${account.email} forbidden (403 VALIDATION_REQUIRED), trying next...`);
441
+ continue;
442
+ }
410
443
  // Handle 5xx errors
411
444
  if (error.message.includes('API error 5') || error.message.includes('500') || error.message.includes('503')) {
412
445
  accountManager.notifyFailure(account, model);
package/src/config.js CHANGED
@@ -43,6 +43,8 @@ const DEFAULT_CONFIG = {
43
43
  maxWaitBeforeErrorMs: 120000, // 2 minutes
44
44
  maxAccounts: 10, // Maximum number of accounts allowed
45
45
  globalQuotaThreshold: 0, // 0 = disabled, 0.01-0.99 = minimum quota fraction before switching accounts
46
+ requestThrottlingEnabled: false, // Opt-in: enable delay before Google API requests
47
+ requestDelayMs: 200, // Delay in ms when throttling enabled (100-5000ms)
46
48
  // Rate limit handling (matches opencode-antigravity-auth)
47
49
  rateLimitDedupWindowMs: 2000, // 2 seconds - prevents concurrent retry storms
48
50
  maxConsecutiveFailures: 3, // Before applying extended cooldown
package/src/constants.js CHANGED
@@ -33,7 +33,7 @@ function getAntigravityDbPath() {
33
33
  function getPlatformUserAgent() {
34
34
  const os = platform();
35
35
  const architecture = arch();
36
- return `antigravity/${ANTIGRAVITY_VERSION} ${os}/${architecture}`;
36
+ return `antigravity/1.16.5 ${os}/${architecture}`;
37
37
  }
38
38
 
39
39
  // IDE Type enum (numeric values as expected by Cloud Code API)
@@ -92,9 +92,6 @@ export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
92
92
  ANTIGRAVITY_ENDPOINT_PROD
93
93
  ];
94
94
 
95
- // Antigravity version used for User-Agent
96
- export const ANTIGRAVITY_VERSION = '1.16.5';
97
-
98
95
  // Required headers for Antigravity API requests
99
96
  export const ANTIGRAVITY_HEADERS = {
100
97
  'User-Agent': getPlatformUserAgent(),
package/src/errors.js CHANGED
@@ -166,6 +166,37 @@ export class CapacityExhaustedError extends AntigravityError {
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * Account forbidden error (403 VALIDATION_REQUIRED / PERMISSION_DENIED)
171
+ * These are account-level errors where the account needs validation or has been
172
+ * disabled. Trying different endpoints won't help - need to rotate to another account.
173
+ */
174
+ export class AccountForbiddenError extends AntigravityError {
175
+ /**
176
+ * @param {string} message - Error message
177
+ * @param {string} accountEmail - Email of the forbidden account
178
+ */
179
+ constructor(message, accountEmail = null) {
180
+ super(message, 'ACCOUNT_FORBIDDEN', false, { accountEmail });
181
+ this.name = 'AccountForbiddenError';
182
+ this.accountEmail = accountEmail;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Check if an error is an account forbidden error (403 VALIDATION_REQUIRED / PERMISSION_DENIED)
188
+ * These errors indicate the account itself is blocked and need account rotation, not endpoint rotation.
189
+ * @param {Error} error - Error to check
190
+ * @returns {boolean}
191
+ */
192
+ export function isAccountForbiddenError(error) {
193
+ if (error instanceof AccountForbiddenError) return true;
194
+ // Fallback string check only for errors that couldn't use the typed class
195
+ // (e.g., errors crossing module boundaries). Only match our own prefixed format.
196
+ const msg = (error.message || '');
197
+ return msg.startsWith('ACCOUNT_FORBIDDEN:');
198
+ }
199
+
169
200
  /**
170
201
  * Check if an error is a rate limit error
171
202
  * Works with both custom error classes and legacy string-based errors
@@ -225,6 +256,7 @@ export default {
225
256
  AntigravityError,
226
257
  RateLimitError,
227
258
  AuthError,
259
+ AccountForbiddenError,
228
260
  NoAccountsError,
229
261
  MaxRetriesError,
230
262
  ApiError,
@@ -233,6 +265,7 @@ export default {
233
265
  CapacityExhaustedError,
234
266
  isRateLimitError,
235
267
  isAuthError,
268
+ isAccountForbiddenError,
236
269
  isEmptyResponseError,
237
270
  isCapacityExhaustedError
238
271
  };
package/src/server.js CHANGED
@@ -576,8 +576,6 @@ app.get('/account-limits', async (req, res) => {
576
576
  // Quota threshold settings
577
577
  quotaThreshold: metadata.quotaThreshold,
578
578
  modelQuotaThresholds: metadata.modelQuotaThresholds || {},
579
- // Include fingerprint existence (details via /api/accounts/:email/fingerprint)
580
- hasFingerprint: !!metadata.fingerprint,
581
579
  // Subscription data (new)
582
580
  subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
583
581
  // Quota limits
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { fileURLToPath } from 'url';
3
3
  import path from 'path';
4
+ import { config } from '../config.js';
4
5
 
5
6
  /**
6
7
  * Shared Utility Functions
@@ -71,6 +72,23 @@ export function isNetworkError(error) {
71
72
  );
72
73
  }
73
74
 
75
+ /**
76
+ * Throttled fetch that applies a configurable delay before each request
77
+ * Only applies delay when requestThrottlingEnabled is true
78
+ * @param {string|URL} url - The URL to fetch
79
+ * @param {RequestInit} [options] - Fetch options
80
+ * @returns {Promise<Response>} Fetch response
81
+ */
82
+ export async function throttledFetch(url, options) {
83
+ if (config.requestThrottlingEnabled) {
84
+ const delayMs = config.requestDelayMs || 200;
85
+ if (delayMs > 0) {
86
+ await sleep(delayMs);
87
+ }
88
+ }
89
+ return fetch(url, options);
90
+ }
91
+
74
92
  /**
75
93
  * Generate random jitter for backoff timing (Thundering Herd Prevention)
76
94
  * Prevents all clients from retrying at the exact same moment after errors.