antigravity-claude-proxy 2.7.7 → 2.8.0

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.
@@ -190,12 +190,18 @@
190
190
  <div class="w-2 h-2 rounded-full flex-shrink-0"
191
191
  :class="acc.status === 'ok' ?
192
192
  'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)] animate-pulse' :
193
- 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'">
193
+ (acc.status === 'banned' ?
194
+ 'bg-red-800 shadow-[0_0_8px_rgba(153,27,27,0.6)]' :
195
+ 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]')">
194
196
  </div>
195
197
  <span class="text-xs font-mono font-semibold"
196
- :class="acc.status === 'ok' ? 'text-neon-green' : 'text-red-400'"
198
+ :class="acc.status === 'ok' ? 'text-neon-green' : (acc.status === 'banned' ? 'text-red-800' : 'text-red-400')"
197
199
  x-text="acc.status.toUpperCase()">
198
200
  </span>
201
+ <p x-show="acc.status === 'banned'"
202
+ class="text-[9px] text-red-600/70 mt-0.5 max-w-[200px] leading-tight"
203
+ x-text="acc.error || 'Gemini disabled for ToS violation'">
204
+ </p>
199
205
  </div>
200
206
  </td>
201
207
  <td class="py-4 pr-6">
@@ -207,6 +213,13 @@
207
213
  x-text="$store.global.t('fix')">
208
214
  FIX
209
215
  </button>
216
+ <!-- Appeal Button (banned accounts only) -->
217
+ <a x-show="acc.status === 'banned'"
218
+ href="mailto:gemini-code-assist-user-feedback@google.com"
219
+ class="px-3 py-1 text-[10px] font-bold font-mono uppercase tracking-wider rounded bg-red-900/20 text-red-400 hover:bg-red-900/30 border border-red-800/30 hover:border-red-800/50 transition-all"
220
+ title="Email Google to appeal this ban">
221
+ APPEAL
222
+ </a>
210
223
  <!-- Settings Button (threshold) -->
211
224
  <button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-neon-yellow transition-colors"
212
225
  @click="openThresholdModal(acc)"
@@ -4,13 +4,15 @@
4
4
  * Handles loading and saving account configuration to disk.
5
5
  */
6
6
 
7
- import { readFile, writeFile, mkdir, access } from 'fs/promises';
7
+ import { readFile, writeFile, mkdir, access, rename } from 'fs/promises';
8
8
  import { constants as fsConstants } from 'fs';
9
9
  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
13
 
14
+ let writeLock = null;
15
+
14
16
  /**
15
17
  * Load accounts from the config file
16
18
  *
@@ -107,8 +109,18 @@ export function loadDefaultAccount(dbPath) {
107
109
  * @param {number} activeIndex - Current active account index
108
110
  */
109
111
  export async function saveAccounts(configPath, accounts, settings, activeIndex) {
112
+ // Serialize writes to prevent concurrent corruption
113
+ const previousLock = writeLock;
114
+ let resolve;
115
+ writeLock = new Promise(r => { resolve = r; });
116
+
117
+ try {
118
+ if (previousLock) await previousLock;
119
+ } catch {
120
+ // Previous write failed, proceed anyway
121
+ }
122
+
110
123
  try {
111
- // Ensure directory exists
112
124
  const dir = dirname(configPath);
113
125
  await mkdir(dir, { recursive: true });
114
126
 
@@ -116,7 +128,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
116
128
  accounts: accounts.map(acc => ({
117
129
  email: acc.email,
118
130
  source: acc.source,
119
- enabled: acc.enabled !== false, // Persist enabled state
131
+ enabled: acc.enabled !== false,
120
132
  dbPath: acc.dbPath || null,
121
133
  refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
122
134
  apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
@@ -127,19 +139,27 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
127
139
  verifyUrl: acc.verifyUrl || null,
128
140
  modelRateLimits: acc.modelRateLimits || {},
129
141
  lastUsed: acc.lastUsed,
130
- // Persist subscription and quota data
131
142
  subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
132
143
  quota: acc.quota || { models: {}, lastChecked: null },
133
- // Persist quota threshold settings
134
- quotaThreshold: acc.quotaThreshold, // undefined omitted from JSON
144
+ quotaThreshold: acc.quotaThreshold,
135
145
  modelQuotaThresholds: Object.keys(acc.modelQuotaThresholds || {}).length > 0 ? acc.modelQuotaThresholds : undefined
136
146
  })),
137
147
  settings: settings,
138
148
  activeIndex: activeIndex
139
149
  };
140
150
 
141
- await writeFile(configPath, JSON.stringify(config, null, 2));
151
+ const json = JSON.stringify(config, null, 2);
152
+
153
+ // Validate JSON before writing (prevent saving corrupt data)
154
+ JSON.parse(json);
155
+
156
+ // Atomic write: write to temp file then rename
157
+ const tmpPath = configPath + '.tmp';
158
+ await writeFile(tmpPath, json);
159
+ await rename(tmpPath, configPath);
142
160
  } catch (error) {
143
161
  logger.error('[AccountManager] Failed to save config:', error.message);
162
+ } finally {
163
+ resolve();
144
164
  }
145
165
  }
@@ -33,6 +33,7 @@ import {
33
33
  isModelCapacityExhausted,
34
34
  isValidationRequired,
35
35
  extractVerificationUrl,
36
+ isAccountBanned,
36
37
  calculateSmartBackoff
37
38
  } from './rate-limit-state.js';
38
39
 
@@ -295,6 +296,14 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
295
296
  throw new AccountForbiddenError(errorText, account.email);
296
297
  }
297
298
 
299
+ // 403 with permanent ToS ban — account is permanently disabled by Google
300
+ // Unlike VALIDATION_REQUIRED, this cannot be resolved by verification
301
+ if (response.status === 403 && isAccountBanned(errorText)) {
302
+ logger.warn(`[CloudCode] 403 ToS BANNED for ${account.email}, marking invalid permanently...`);
303
+ accountManager.markInvalid(account.email, 'Account banned — Gemini disabled for Terms of Service violation');
304
+ throw new AccountForbiddenError(errorText, account.email);
305
+ }
306
+
298
307
  lastError = new Error(`API error ${response.status}: ${errorText}`);
299
308
  // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
300
309
  if (response.status === 403 || response.status === 404) {
@@ -96,6 +96,13 @@ export async function fetchAvailableModels(token, projectId = null) {
96
96
  if (!response.ok) {
97
97
  const errorText = await response.text();
98
98
  logger.warn(`[CloudCode] fetchAvailableModels error at ${endpoint}: ${response.status}`);
99
+ // Detect permanent ToS ban — no point trying other endpoints
100
+ if (response.status === 403) {
101
+ const lower = (errorText || '').toLowerCase();
102
+ if (lower.includes('has been disabled') && lower.includes('violation of terms of service')) {
103
+ throw new Error(`ACCOUNT_BANNED: ${errorText}`);
104
+ }
105
+ }
99
106
  continue;
100
107
  }
101
108
 
@@ -189,7 +196,15 @@ export async function getSubscriptionTier(token) {
189
196
  });
190
197
 
191
198
  if (!response.ok) {
199
+ const errorText = await response.text().catch(() => '');
192
200
  logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`);
201
+ // Detect permanent ToS ban — no point trying other endpoints
202
+ if (response.status === 403) {
203
+ const lower = (errorText || '').toLowerCase();
204
+ if (lower.includes('has been disabled') && lower.includes('violation of terms of service')) {
205
+ throw new Error(`ACCOUNT_BANNED: ${errorText}`);
206
+ }
207
+ }
193
208
  continue;
194
209
  }
195
210
 
@@ -139,6 +139,18 @@ export function extractVerificationUrl(errorText) {
139
139
  return raw[0].replace(/[,.)}>\]]+$/, '');
140
140
  }
141
141
 
142
+ /**
143
+ * Detect if 403 error is due to a permanent account ban (ToS violation).
144
+ * These accounts are permanently disabled by Google and cannot be recovered
145
+ * by retrying or re-authenticating. User must contact Google support to appeal.
146
+ * @param {string} errorText - Error message from API
147
+ * @returns {boolean} True if account is permanently banned
148
+ */
149
+ export function isAccountBanned(errorText) {
150
+ const lower = (errorText || '').toLowerCase();
151
+ return lower.includes('has been disabled') && lower.includes('violation of terms of service');
152
+ }
153
+
142
154
  /**
143
155
  * Detect if 429 error is due to model capacity (not user quota).
144
156
  * Capacity issues should retry on same account with shorter delay.
@@ -32,6 +32,7 @@ import {
32
32
  isModelCapacityExhausted,
33
33
  isValidationRequired,
34
34
  extractVerificationUrl,
35
+ isAccountBanned,
35
36
  calculateSmartBackoff
36
37
  } from './rate-limit-state.js';
37
38
  import crypto from 'crypto';
@@ -290,6 +291,14 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
290
291
  throw new AccountForbiddenError(errorText, account.email);
291
292
  }
292
293
 
294
+ // 403 with permanent ToS ban — account is permanently disabled by Google
295
+ // Unlike VALIDATION_REQUIRED, this cannot be resolved by verification
296
+ if (response.status === 403 && isAccountBanned(errorText)) {
297
+ logger.warn(`[CloudCode] 403 ToS BANNED for ${account.email}, marking invalid permanently...`);
298
+ accountManager.markInvalid(account.email, 'Account banned — Gemini disabled for Terms of Service violation');
299
+ throw new AccountForbiddenError(errorText, account.email);
300
+ }
301
+
293
302
  lastError = new Error(`API error ${response.status}: ${errorText}`);
294
303
 
295
304
  // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
@@ -19,7 +19,8 @@ import {
19
19
  hasUnsignedThinkingBlocks,
20
20
  needsThinkingRecovery,
21
21
  closeToolLoopForThinking,
22
- cleanCacheControl
22
+ cleanCacheControl,
23
+ clampGeminiThinkingBudget
23
24
  } from './thinking-utils.js';
24
25
  import { logger } from '../utils/logger.js';
25
26
 
@@ -165,31 +166,32 @@ export function convertAnthropicToGoogle(anthropicRequest) {
165
166
  include_thoughts: true
166
167
  };
167
168
 
168
- // Only set thinking_budget if explicitly provided
169
- const thinkingBudget = thinking?.budget_tokens;
170
- if (thinkingBudget) {
171
- thinkingConfig.thinking_budget = thinkingBudget;
172
- logger.debug(`[RequestConverter] Claude thinking enabled with budget: ${thinkingBudget}`);
173
-
174
- // Validate max_tokens > thinking_budget as required by the API
175
- const currentMaxTokens = googleRequest.generationConfig.maxOutputTokens;
176
- if (currentMaxTokens && currentMaxTokens <= thinkingBudget) {
177
- // Bump max_tokens to allow for some response content
178
- // Default to budget + 8192 (standard output buffer)
179
- const adjustedMaxTokens = thinkingBudget + 8192;
169
+ // Cloud Code API requires thinking_budget to actually produce thinking blocks.
170
+ // Without it, include_thoughts alone is ignored and Claude falls back to
171
+ // <thinking> XML tags in text. Default to 32000 when not provided (e.g. adaptive mode).
172
+ const thinkingBudget = thinking?.budget_tokens || 32000;
173
+ thinkingConfig.thinking_budget = thinkingBudget;
174
+ logger.debug(`[RequestConverter] Claude thinking enabled with budget: ${thinkingBudget}${!thinking?.budget_tokens ? ' (default)' : ''}`);
175
+
176
+ // Validate max_tokens > thinking_budget as required by the API
177
+ const currentMaxTokens = googleRequest.generationConfig.maxOutputTokens;
178
+ if (currentMaxTokens && currentMaxTokens <= thinkingBudget) {
179
+ const adjustedMaxTokens = thinkingBudget + 8192;
180
+ if (thinking?.budget_tokens) {
180
181
  logger.warn(`[RequestConverter] max_tokens (${currentMaxTokens}) <= thinking_budget (${thinkingBudget}). Adjusting to ${adjustedMaxTokens} to satisfy API requirements`);
181
- googleRequest.generationConfig.maxOutputTokens = adjustedMaxTokens;
182
+ } else {
183
+ logger.debug(`[RequestConverter] Adjusting max_tokens to ${adjustedMaxTokens} for default thinking budget`);
182
184
  }
183
- } else {
184
- logger.debug('[RequestConverter] Claude thinking enabled (no budget specified)');
185
+ googleRequest.generationConfig.maxOutputTokens = adjustedMaxTokens;
185
186
  }
186
187
 
187
188
  googleRequest.generationConfig.thinkingConfig = thinkingConfig;
188
189
  } else if (isGeminiModel) {
189
190
  // Gemini thinking config (uses camelCase)
191
+ // Clamp budget to model-specific max (e.g., Gemini 2.5 Flash max is 24,576)
190
192
  const thinkingConfig = {
191
193
  includeThoughts: true,
192
- thinkingBudget: thinking?.budget_tokens || 16000
194
+ thinkingBudget: clampGeminiThinkingBudget(modelName, thinking?.budget_tokens)
193
195
  };
194
196
  logger.debug(`[RequestConverter] Gemini thinking enabled with budget: ${thinkingConfig.thinkingBudget}`);
195
197
 
@@ -7,6 +7,56 @@ import { MIN_SIGNATURE_LENGTH } from '../constants.js';
7
7
  import { getCachedSignatureFamily } from './signature-cache.js';
8
8
  import { logger } from '../utils/logger.js';
9
9
 
10
+ // ============================================================================
11
+ // Gemini Thinking Budget Limits (Issue #289)
12
+ // ============================================================================
13
+
14
+ // Max thinking budget per Gemini model version
15
+ // Gemini 2.5 Flash: max 24,576 (API error: "supported values are integers from 1 to 24576")
16
+ const GEMINI_THINKING_BUDGET_LIMITS = {
17
+ '2.5': 24576,
18
+ };
19
+ const GEMINI_DEFAULT_THINKING_BUDGET = 16000;
20
+ const GEMINI_DEFAULT_THINKING_BUDGET_LIMIT = 128000;
21
+
22
+ /**
23
+ * Clamp thinking budget to the maximum supported by a Gemini model.
24
+ * Different Gemini versions have different limits (e.g., 2.5 Flash max is 24,576).
25
+ *
26
+ * @param {string} modelName - The Gemini model name
27
+ * @param {number|undefined} budget - Requested thinking budget from the client
28
+ * @returns {number} Clamped thinking budget
29
+ */
30
+ export function clampGeminiThinkingBudget(modelName, budget) {
31
+ const requestedBudget = budget || GEMINI_DEFAULT_THINKING_BUDGET;
32
+ const lower = (modelName || '').toLowerCase();
33
+
34
+ // Extract version like "2.5" or "3" from "gemini-2.5-flash-thinking"
35
+ const versionMatch = lower.match(/gemini-(\d+(?:\.\d+)?)/);
36
+ let maxBudget = GEMINI_DEFAULT_THINKING_BUDGET_LIMIT;
37
+
38
+ if (versionMatch) {
39
+ const version = versionMatch[1];
40
+ // Check exact version (e.g., "2.5")
41
+ if (GEMINI_THINKING_BUDGET_LIMITS[version]) {
42
+ maxBudget = GEMINI_THINKING_BUDGET_LIMITS[version];
43
+ } else {
44
+ // Check major version (e.g., "2" for "2.5")
45
+ const major = version.split('.')[0];
46
+ if (GEMINI_THINKING_BUDGET_LIMITS[major]) {
47
+ maxBudget = GEMINI_THINKING_BUDGET_LIMITS[major];
48
+ }
49
+ }
50
+ }
51
+
52
+ if (requestedBudget > maxBudget) {
53
+ logger.debug(`[ThinkingUtils] Clamping Gemini thinking budget from ${requestedBudget} to ${maxBudget} for ${modelName}`);
54
+ return maxBudget;
55
+ }
56
+
57
+ return requestedBudget;
58
+ }
59
+
10
60
  // ============================================================================
11
61
  // Cache Control Cleaning (Issue #189)
12
62
  // ============================================================================
package/src/server.js CHANGED
@@ -259,9 +259,11 @@ app.get('/health', async (req, res) => {
259
259
 
260
260
  // Skip invalid accounts for quota check
261
261
  if (account.isInvalid) {
262
+ const isBanned = account.invalidReason?.toLowerCase().includes('banned') ||
263
+ account.invalidReason?.toLowerCase().includes('terms of service');
262
264
  return {
263
265
  ...baseInfo,
264
- status: 'invalid',
266
+ status: isBanned ? 'banned' : 'invalid',
265
267
  error: account.invalidReason,
266
268
  models: {}
267
269
  };
@@ -394,6 +396,17 @@ app.get('/account-limits', async (req, res) => {
394
396
  models: quotas
395
397
  };
396
398
  } catch (error) {
399
+ // Detect ToS ban from quota/subscription fetch and mark account invalid
400
+ if (error.message?.startsWith('ACCOUNT_BANNED:')) {
401
+ accountManager.markInvalid(account.email, 'Account banned — Gemini disabled for Terms of Service violation');
402
+ return {
403
+ email: account.email,
404
+ status: 'banned',
405
+ error: 'Account banned — Gemini disabled for Terms of Service violation',
406
+ subscription: account.subscription || { tier: 'unknown', projectId: null },
407
+ models: {}
408
+ };
409
+ }
397
410
  return {
398
411
  email: account.email,
399
412
  status: 'error',
@@ -10,7 +10,7 @@ import { existsSync } from 'fs';
10
10
  */
11
11
 
12
12
  // Fallback constant
13
- const FALLBACK_ANTIGRAVITY_VERSION = '1.18.3';
13
+ const FALLBACK_ANTIGRAVITY_VERSION = '1.18.4';
14
14
 
15
15
  // Cache for the generated User-Agent string
16
16
  let cachedUserAgent = null;