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.
- package/package.json +1 -1
- package/public/css/style.css +1 -1
- package/public/js/components/account-manager.js +9 -0
- package/public/js/components/server-config.js +32 -0
- package/public/js/config/constants.js +4 -0
- package/public/views/settings.html +58 -0
- package/src/account-manager/credentials.js +2 -2
- package/src/account-manager/index.js +29 -3
- package/src/account-manager/onboarding.js +2 -2
- package/src/account-manager/rate-limits.js +27 -1
- package/src/account-manager/storage.js +8 -4
- package/src/auth/oauth.js +5 -4
- package/src/cloudcode/message-handler.js +36 -3
- package/src/cloudcode/model-api.js +3 -2
- package/src/cloudcode/rate-limit-state.js +41 -0
- package/src/cloudcode/streaming-handler.js +38 -5
- package/src/config.js +2 -0
- package/src/errors.js +33 -0
- package/src/server.js +2 -2
- package/src/utils/fingerprint.js +1 -1
- package/src/utils/helpers.js +18 -0
- package/src/webui/index.js +16 -1
|
@@ -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
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|