antigravity-claude-proxy 2.7.0 → 2.7.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.
@@ -118,6 +118,15 @@ window.Components.accountManager = () => ({
118
118
 
119
119
  async fixAccount(email) {
120
120
  const store = Alpine.store('global');
121
+ const dataStore = Alpine.store('data');
122
+ // If the account has a verification URL (403 VALIDATION_REQUIRED), open it directly
123
+ const account = (dataStore.accounts || []).find(a => a.email === email);
124
+ if (account?.verifyUrl) {
125
+ window.open(account.verifyUrl, '_blank');
126
+ store.showToast(store.t('verifyThenRefresh') || 'After completing verification, click the ↻ Refresh button to re-enable this account', 'info', 10000);
127
+ return;
128
+ }
129
+ // Otherwise fall back to OAuth re-auth
121
130
  store.showToast(store.t('reauthenticating', { email: Redact.email(email) }), 'info');
122
131
  const password = store.webuiPassword;
123
132
  try {
@@ -323,6 +323,38 @@ window.Components.serverConfig = () => ({
323
323
  }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
324
324
  },
325
325
 
326
+ async toggleRequestThrottling(enabled) {
327
+ const store = Alpine.store('global');
328
+ const previousValue = this.serverConfig.requestThrottlingEnabled;
329
+ this.serverConfig.requestThrottlingEnabled = enabled;
330
+
331
+ try {
332
+ const { response, newPassword } = await window.utils.request('/api/config', {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify({ requestThrottlingEnabled: enabled })
336
+ }, store.webuiPassword);
337
+
338
+ if (newPassword) store.webuiPassword = newPassword;
339
+
340
+ const data = await response.json();
341
+ if (data.status === 'ok') {
342
+ store.showToast(`Request Throttling ${enabled ? 'enabled' : 'disabled'}`, 'success');
343
+ } else {
344
+ throw new Error(data.error || 'Failed to update');
345
+ }
346
+ } catch (e) {
347
+ this.serverConfig.requestThrottlingEnabled = previousValue;
348
+ store.showToast('Failed to update Request Throttling: ' + e.message, 'error');
349
+ }
350
+ },
351
+
352
+ toggleRequestDelayMs(value) {
353
+ const { REQUEST_DELAY_MIN, REQUEST_DELAY_MAX } = window.AppConstants.VALIDATION;
354
+ this.saveConfigField('requestDelayMs', value, 'Request Delay',
355
+ (v) => window.Validators.validateRange(v, REQUEST_DELAY_MIN, REQUEST_DELAY_MAX, 'Request Delay'));
356
+ },
357
+
326
358
  toggleMaxAccounts(value) {
327
359
  const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
328
360
  this.saveConfigField('maxAccounts', value, 'Max Accounts',
@@ -96,6 +96,10 @@ window.AppConstants.VALIDATION = {
96
96
  GLOBAL_QUOTA_THRESHOLD_MIN: 0,
97
97
  GLOBAL_QUOTA_THRESHOLD_MAX: 99,
98
98
 
99
+ // Request delay (100 - 5000ms)
100
+ REQUEST_DELAY_MIN: 100,
101
+ REQUEST_DELAY_MAX: 5000,
102
+
99
103
  // Switch account delay (1s - 60s)
100
104
  SWITCH_ACCOUNT_DELAY_MIN: 1000,
101
105
  SWITCH_ACCOUNT_DELAY_MAX: 60000,
@@ -1750,6 +1750,64 @@
1750
1750
  </div>
1751
1751
  </div>
1752
1752
 
1753
+ <!-- Request Throttling -->
1754
+ <div class="space-y-4 pt-2 border-t border-space-border/10">
1755
+ <div class="flex items-center gap-2 mb-2">
1756
+ <span class="text-[10px] text-gray-500 font-bold uppercase tracking-widest">Request Throttling</span>
1757
+ <span class="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide rounded bg-amber-500/20 text-amber-400 border border-amber-500/40">Experimental</span>
1758
+ </div>
1759
+
1760
+ <!-- Request Throttling Card -->
1761
+ <div class="form-control bg-space-900/50 rounded-lg border transition-all duration-300 hover:border-amber-500/50"
1762
+ :class="serverConfig.requestThrottlingEnabled ? 'border-amber-500 bg-amber-500/10 shadow-[0_0_25px_rgba(245,158,11,0.2)] ring-1 ring-amber-500/30' : 'border-space-border/50'">
1763
+
1764
+ <!-- Toggle Row -->
1765
+ <div class="flex items-center justify-between p-4">
1766
+ <div class="flex flex-col gap-1">
1767
+ <span class="label-text font-medium transition-colors"
1768
+ :class="serverConfig.requestThrottlingEnabled ? 'text-amber-300' : 'text-gray-300'">Enable Request Throttling</span>
1769
+ <span class="text-xs transition-colors"
1770
+ :class="serverConfig.requestThrottlingEnabled ? 'text-amber-100/50' : 'text-gray-500'">Add delay before each Google API request to avoid rate limits.</span>
1771
+ </div>
1772
+ <label class="relative inline-flex items-center cursor-pointer">
1773
+ <input type="checkbox" class="sr-only peer"
1774
+ :checked="serverConfig.requestThrottlingEnabled"
1775
+ @change="toggleRequestThrottling($event.target.checked)"
1776
+ aria-label="Request throttling toggle">
1777
+ <div
1778
+ class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
1779
+ </div>
1780
+ </label>
1781
+ </div>
1782
+
1783
+ <!-- Delay Slider (only shown when enabled) -->
1784
+ <div x-show="serverConfig.requestThrottlingEnabled" x-transition x-collapse>
1785
+ <div class="border-t border-amber-500/20 px-4 pb-4 pt-3">
1786
+ <label class="flex justify-between items-center mb-2">
1787
+ <span class="text-xs text-amber-200/70">Delay per Request</span>
1788
+ <span class="font-mono text-amber-400 text-xs font-semibold"
1789
+ x-text="(serverConfig.requestDelayMs || 200) + 'ms'"></span>
1790
+ </label>
1791
+ <div class="flex gap-3 items-center">
1792
+ <input type="range" min="100" max="5000" step="100"
1793
+ class="custom-range custom-range-yellow flex-1"
1794
+ :value="serverConfig.requestDelayMs || 200"
1795
+ :style="`background-size: ${((serverConfig.requestDelayMs || 200) - 100) / 49}% 100%`"
1796
+ @input="toggleRequestDelayMs($event.target.value)"
1797
+ aria-label="Request delay slider">
1798
+ <input type="number" min="100" max="5000" step="100"
1799
+ class="input input-xs input-bordered w-20 bg-space-800/50 border-amber-500/30 text-white font-mono text-center"
1800
+ :value="serverConfig.requestDelayMs || 200"
1801
+ @change="toggleRequestDelayMs($event.target.value)"
1802
+ aria-label="Request delay value">
1803
+ </div>
1804
+ <p class="text-[9px] text-amber-100/40 mt-2 leading-tight">
1805
+ Milliseconds to wait before each API call (token refresh, quota check, messages, etc).</p>
1806
+ </div>
1807
+ </div>
1808
+ </div>
1809
+ </div>
1810
+
1753
1811
  <!-- Error Handling Tuning -->
1754
1812
  <div class="space-y-4 pt-2 border-t border-space-border/10">
1755
1813
  <div class="flex items-center gap-2 mb-2">
@@ -15,7 +15,7 @@ import {
15
15
  import { refreshAccessToken, parseRefreshParts, formatRefreshParts } from '../auth/oauth.js';
16
16
  import { getAuthStatus } from '../auth/database.js';
17
17
  import { logger } from '../utils/logger.js';
18
- import { isNetworkError } from '../utils/helpers.js';
18
+ import { isNetworkError, throttledFetch } from '../utils/helpers.js';
19
19
  import { onboardUser, getDefaultTierId } from './onboarding.js';
20
20
  import { parseTierId } from '../cloudcode/model-api.js';
21
21
 
@@ -228,7 +228,7 @@ export async function discoverProject(token, projectId = undefined) {
228
228
 
229
229
  for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
230
230
  try {
231
- const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
231
+ const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, {
232
232
  method: 'POST',
233
233
  headers: {
234
234
  'Authorization': `Bearer ${token}`,
@@ -15,6 +15,7 @@ import {
15
15
  resetAllRateLimits as resetLimits,
16
16
  markRateLimited as markLimited,
17
17
  markInvalid as markAccountInvalid,
18
+ clearInvalid as clearAccountInvalid,
18
19
  getMinWaitTimeMs as getMinWait,
19
20
  getRateLimitInfo as getLimitInfo,
20
21
  getConsecutiveFailures as getFailures,
@@ -138,6 +139,16 @@ export class AccountManager {
138
139
  return getInvalid(this.#accounts);
139
140
  }
140
141
 
142
+ /**
143
+ * Check if all enabled accounts are invalid (need user intervention).
144
+ * Unlike rate limits, invalid accounts won't self-recover — waiting is pointless.
145
+ * @returns {boolean} True if every enabled account is invalid
146
+ */
147
+ isAllAccountsInvalid() {
148
+ const enabled = this.#accounts.filter(a => a.enabled !== false);
149
+ return enabled.length > 0 && enabled.every(a => a.isInvalid);
150
+ }
151
+
141
152
  /**
142
153
  * Clear expired rate limits
143
154
  * @returns {number} Number of rate limits cleared
@@ -283,9 +294,19 @@ export class AccountManager {
283
294
  * Mark an account as invalid (credentials need re-authentication)
284
295
  * @param {string} email - Email of the account to mark
285
296
  * @param {string} reason - Reason for marking as invalid
297
+ * @param {string|null} verifyUrl - Optional verification URL (for 403 VALIDATION_REQUIRED)
298
+ */
299
+ markInvalid(email, reason = 'Unknown error', verifyUrl = null) {
300
+ markAccountInvalid(this.#accounts, email, reason, verifyUrl);
301
+ this.saveToDisk();
302
+ }
303
+
304
+ /**
305
+ * Clear invalid status for an account (after user completes verification)
306
+ * @param {string} email - Email of the account to clear
286
307
  */
287
- markInvalid(email, reason = 'Unknown error') {
288
- markAccountInvalid(this.#accounts, email, reason);
308
+ clearInvalid(email) {
309
+ clearAccountInvalid(this.#accounts, email);
289
310
  this.saveToDisk();
290
311
  }
291
312
 
@@ -434,11 +455,12 @@ export class AccountManager {
434
455
  modelRateLimits: a.modelRateLimits || {},
435
456
  isInvalid: a.isInvalid || false,
436
457
  invalidReason: a.invalidReason || null,
458
+ verifyUrl: a.verifyUrl || null,
437
459
  lastUsed: a.lastUsed,
438
460
  // Include quota threshold settings
439
461
  quotaThreshold: a.quotaThreshold,
440
462
  modelQuotaThresholds: a.modelQuotaThresholds || {},
441
- fingerprint: a.fingerprint
463
+ hasFingerprint: !!a.fingerprint
442
464
  }))
443
465
  };
444
466
  }
@@ -560,6 +582,10 @@ export class AccountManager {
560
582
  account.fingerprintHistory.unshift(historyEntry);
561
583
  }
562
584
 
585
+ // Remove the restored entry from history (shifted by 1 if we just unshifted)
586
+ const removeIndex = account.fingerprint ? historyIndex + 1 : historyIndex;
587
+ account.fingerprintHistory.splice(removeIndex, 1);
588
+
563
589
  // Restore
564
590
  account.fingerprint = { ...restoredEntry.fingerprint, createdAt: Date.now() };
565
591
 
@@ -10,7 +10,7 @@ import {
10
10
  CLIENT_METADATA
11
11
  } from '../constants.js';
12
12
  import { logger } from '../utils/logger.js';
13
- import { sleep } from '../utils/helpers.js';
13
+ import { sleep, throttledFetch } from '../utils/helpers.js';
14
14
 
15
15
  /**
16
16
  * Get the default tier ID from allowed tiers list
@@ -64,7 +64,7 @@ export async function onboardUser(token, tierId, projectId = undefined, maxAttem
64
64
  for (const endpoint of ONBOARD_USER_ENDPOINTS) {
65
65
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
66
66
  try {
67
- const response = await fetch(`${endpoint}/v1internal:onboardUser`, {
67
+ const response = await throttledFetch(`${endpoint}/v1internal:onboardUser`, {
68
68
  method: 'POST',
69
69
  headers: {
70
70
  'Authorization': `Bearer ${token}`,
@@ -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
  *
@@ -29,9 +29,11 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
29
29
  ...acc,
30
30
  lastUsed: acc.lastUsed || null,
31
31
  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,
32
+ // Reset invalid flag on startup - give accounts a fresh chance
33
+ // EXCEPT accounts with a verifyUrl — those need user intervention
34
+ isInvalid: acc.verifyUrl ? (acc.isInvalid || false) : false,
35
+ invalidReason: acc.verifyUrl ? (acc.invalidReason || null) : null,
36
+ verifyUrl: acc.verifyUrl || null,
35
37
  modelRateLimits: acc.modelRateLimits || {},
36
38
  // New fields for subscription and quota tracking
37
39
  subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
@@ -82,7 +84,8 @@ export function loadDefaultAccount(dbPath) {
82
84
  email: authData.email || 'default@antigravity',
83
85
  source: 'database',
84
86
  lastUsed: null,
85
- modelRateLimits: {}
87
+ modelRateLimits: {},
88
+ fingerprint: generateFingerprint()
86
89
  };
87
90
 
88
91
  const tokenCache = new Map();
@@ -128,6 +131,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
128
131
  addedAt: acc.addedAt || undefined,
129
132
  isInvalid: acc.isInvalid || false,
130
133
  invalidReason: acc.invalidReason || null,
134
+ verifyUrl: acc.verifyUrl || null,
131
135
  modelRateLimits: acc.modelRateLimits || {},
132
136
  lastUsed: acc.lastUsed,
133
137
  // Persist subscription and quota data
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}`,
@@ -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,7 +155,7 @@ 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
160
  headers: buildHeaders(token, model, isThinking ? 'text/event-stream' : 'application/json', account.fingerprint),
149
161
  body: JSON.stringify(payload)
@@ -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.
@@ -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,7 +153,7 @@ 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
158
  headers: buildHeaders(token, model, 'text/event-stream', account.fingerprint),
147
159
  body: JSON.stringify(payload)
@@ -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,7 +334,7 @@ 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
339
  headers: buildHeaders(token, model, 'text/event-stream', account.fingerprint),
318
340
  body: JSON.stringify(payload)
@@ -345,7 +367,7 @@ 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
372
  headers: buildHeaders(token, model, 'text/event-stream', account.fingerprint),
351
373
  body: JSON.stringify(payload)
@@ -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);