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.
- package/README.md +0 -1
- package/package.json +1 -1
- package/public/css/style.css +1 -1
- package/public/index.html +0 -2
- package/public/js/components/account-manager.js +9 -121
- package/public/js/components/server-config.js +32 -0
- package/public/js/config/constants.js +4 -0
- package/public/views/accounts.html +0 -149
- package/public/views/settings.html +58 -0
- package/src/account-manager/credentials.js +2 -2
- package/src/account-manager/index.js +25 -87
- package/src/account-manager/onboarding.js +2 -2
- package/src/account-manager/rate-limits.js +27 -1
- package/src/account-manager/storage.js +9 -16
- package/src/auth/oauth.js +5 -4
- package/src/cli/accounts.js +2 -4
- package/src/cloudcode/message-handler.js +37 -4
- package/src/cloudcode/model-api.js +3 -2
- package/src/cloudcode/rate-limit-state.js +41 -0
- package/src/cloudcode/request-builder.js +2 -7
- package/src/cloudcode/streaming-handler.js +41 -8
- package/src/config.js +2 -0
- package/src/constants.js +1 -4
- package/src/errors.js +33 -0
- package/src/server.js +0 -2
- package/src/utils/helpers.js +18 -0
- package/src/webui/index.js +16 -73
- package/src/utils/fingerprint.js +0 -133
|
@@ -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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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}`,
|
package/src/cli/accounts.js
CHANGED
|
@@ -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
|
|
158
|
+
const response = await throttledFetch(url, {
|
|
147
159
|
method: 'POST',
|
|
148
|
-
headers: buildHeaders(token, model, isThinking ? 'text/event-stream' : 'application/json'
|
|
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
|
|
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.
|
|
@@ -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'
|
|
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
|
|
156
|
+
const response = await throttledFetch(url, {
|
|
145
157
|
method: 'POST',
|
|
146
|
-
headers: buildHeaders(token, model, 'text/event-stream'
|
|
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
|
|
337
|
+
currentResponse = await throttledFetch(url, {
|
|
316
338
|
method: 'POST',
|
|
317
|
-
headers: buildHeaders(token, model, 'text/event-stream'
|
|
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
|
|
370
|
+
currentResponse = await throttledFetch(url, {
|
|
349
371
|
method: 'POST',
|
|
350
|
-
headers: buildHeaders(token, model, 'text/event-stream'
|
|
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
|
|
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
|
package/src/utils/helpers.js
CHANGED
|
@@ -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.
|