antigravity-claude-proxy 2.4.0 → 2.4.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/README.md CHANGED
@@ -84,6 +84,8 @@ Choose one of the following methods to authorize the proxy:
84
84
  2. Navigate to the **Accounts** tab and click **Add Account**.
85
85
  3. Complete the Google OAuth authorization in the popup window.
86
86
 
87
+ > **Headless/Remote Servers**: If running on a server without a browser, the WebUI supports a "Manual Authorization" mode. After clicking "Add Account", you can copy the OAuth URL, complete authorization on your local machine, and paste the authorization code back.
88
+
87
89
  #### **Method B: CLI (Desktop or Headless)**
88
90
 
89
91
  If you prefer the terminal or are on a remote server:
@@ -152,15 +154,13 @@ Add this configuration:
152
154
  "ANTHROPIC_MODEL": "claude-opus-4-5-thinking",
153
155
  "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-5-thinking",
154
156
  "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-thinking",
155
- "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash-lite[1m]",
157
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-sonnet-4-5",
156
158
  "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-5-thinking",
157
159
  "ENABLE_EXPERIMENTAL_MCP_CLI": "true"
158
160
  }
159
161
  }
160
162
  ```
161
163
 
162
- (Please use **gemini-2.5-flash-lite** as the default haiku model, even if others are claude, as claude code makes several calls via the haiku model for background tasks. If you use claude model for it, you may use you claude usage sooner)
163
-
164
164
  Or to use Gemini models:
165
165
 
166
166
  ```json
@@ -171,7 +171,7 @@ Or to use Gemini models:
171
171
  "ANTHROPIC_MODEL": "gemini-3-pro-high[1m]",
172
172
  "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-high[1m]",
173
173
  "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-flash[1m]",
174
- "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash-lite[1m]",
174
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-3-flash[1m]",
175
175
  "CLAUDE_CODE_SUBAGENT_MODEL": "gemini-3-flash[1m]",
176
176
  "ENABLE_EXPERIMENTAL_MCP_CLI": "true"
177
177
  }
@@ -280,7 +280,7 @@ Choose a strategy based on your needs:
280
280
 
281
281
  | Strategy | Best For | Description |
282
282
  | --- | --- | --- |
283
- | **Hybrid** (Default) | Most users | Smart selection combining health score, token bucket rate limiting, and LRU freshness |
283
+ | **Hybrid** (Default) | Most users | Smart selection combining health score, token bucket rate limiting, quota awareness, and LRU freshness |
284
284
  | **Sticky** | Prompt caching | Stays on the same account to maximize cache hits, switches only when rate-limited |
285
285
  | **Round-Robin** | Even distribution | Cycles through accounts sequentially for balanced load |
286
286
 
@@ -298,6 +298,8 @@ antigravity-claude-proxy start --strategy=round-robin # Load-balanced
298
298
 
299
299
  - **Health Score Tracking**: Accounts earn points for successful requests and lose points for failures/rate-limits
300
300
  - **Token Bucket Rate Limiting**: Client-side throttling with regenerating tokens (50 max, 6/minute)
301
+ - **Quota Awareness**: Accounts with critical quota (<5%) are deprioritized; exhausted accounts trigger emergency fallback
302
+ - **Emergency Fallback**: When all accounts appear exhausted, bypasses checks with throttle delays (250-500ms)
301
303
  - **Automatic Cooldown**: Rate-limited accounts recover automatically after reset time expires
302
304
  - **Invalid Account Detection**: Accounts needing re-authentication are marked and skipped
303
305
  - **Prompt Caching Support**: Session IDs derived from conversation enable cache hits across turns
@@ -340,13 +342,14 @@ The proxy includes a built-in, modern web interface for real-time monitoring and
340
342
  - **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
341
343
  - **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
342
344
  - **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance.
345
+ - **Manual OAuth Mode**: Add accounts on headless servers by copying the OAuth URL and pasting the authorization code.
343
346
  - **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
344
347
  - **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts.
345
348
  - **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods.
346
349
  - **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views.
347
350
  - **Live Logs**: Stream server logs with level-based filtering and search.
348
351
  - **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
349
- - **Bilingual Interface**: Full support for English and Chinese (switch via Settings).
352
+ - **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), and Portuguese (PT-BR).
350
353
 
351
354
  ---
352
355
 
@@ -360,9 +363,11 @@ While most users can use the default settings, you can tune the proxy behavior v
360
363
  - **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config.
361
364
  - **Custom Port**: Change the default `8080` port.
362
365
  - **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
366
+ - **Rate Limit Handling**: Comprehensive rate limit detection from headers and error messages with intelligent retry-after parsing.
363
367
  - **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
364
368
  - **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
365
369
  - **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
370
+ - **Endpoint Fallback**: Automatic 403/404 endpoint fallback for API compatibility.
366
371
 
367
372
  Refer to `config.example.json` for a complete list of fields and documentation.
368
373
 
@@ -421,12 +426,71 @@ npm run test:interleaved # Interleaved thinking
421
426
  npm run test:images # Image processing
422
427
  npm run test:caching # Prompt caching
423
428
  npm run test:strategies # Account selection strategies
429
+ npm run test:cache-control # Cache control field stripping
424
430
  ```
425
431
 
426
432
  ---
427
433
 
428
434
  ## Troubleshooting
429
435
 
436
+ ### Windows: OAuth Port Error (EACCES)
437
+
438
+ On Windows, the default OAuth callback port (51121) may be reserved by Hyper-V, WSL2, or Docker. If you see:
439
+
440
+ ```
441
+ Error: listen EACCES: permission denied 0.0.0.0:51121
442
+ ```
443
+
444
+ The proxy will automatically try fallback ports (51122-51126). If all ports fail, try these solutions:
445
+
446
+ #### Option 1: Use a Custom Port (Recommended)
447
+
448
+ Set a custom port outside the reserved range:
449
+
450
+ ```bash
451
+ # Windows PowerShell
452
+ $env:OAUTH_CALLBACK_PORT = "3456"
453
+ antigravity-claude-proxy start
454
+
455
+ # Windows CMD
456
+ set OAUTH_CALLBACK_PORT=3456
457
+ antigravity-claude-proxy start
458
+
459
+ # Or add to your .env file
460
+ OAUTH_CALLBACK_PORT=3456
461
+ ```
462
+
463
+ #### Option 2: Reset Windows NAT
464
+
465
+ Run as Administrator:
466
+
467
+ ```powershell
468
+ net stop winnat
469
+ net start winnat
470
+ ```
471
+
472
+ #### Option 3: Check Reserved Ports
473
+
474
+ See which ports are reserved:
475
+
476
+ ```powershell
477
+ netsh interface ipv4 show excludedportrange protocol=tcp
478
+ ```
479
+
480
+ If 51121 is in a reserved range, use Option 1 with a port outside those ranges.
481
+
482
+ #### Option 4: Permanently Exclude Port (Admin)
483
+
484
+ Reserve the port before Hyper-V claims it (run as Administrator):
485
+
486
+ ```powershell
487
+ netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1
488
+ ```
489
+
490
+ > **Note:** The server automatically tries fallback ports (51122-51126) if the primary port fails.
491
+
492
+ ---
493
+
430
494
  ### "Could not extract token from Antigravity"
431
495
 
432
496
  If using single-account mode with Antigravity:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antigravity-claude-proxy",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -35,7 +35,8 @@
35
35
  "test:oauth": "node tests/test-oauth-no-browser.cjs",
36
36
  "test:emptyretry": "node tests/test-empty-response-retry.cjs",
37
37
  "test:sanitizer": "node tests/test-schema-sanitizer.cjs",
38
- "test:strategies": "node tests/test-strategies.cjs"
38
+ "test:strategies": "node tests/test-strategies.cjs",
39
+ "test:cache-control": "node tests/test-cache-control.cjs"
39
40
  },
40
41
  "keywords": [
41
42
  "claude",
package/public/index.html CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  <body
20
20
  class="bg-space-950 text-gray-300 font-sans antialiased min-h-screen overflow-hidden selection:bg-neon-purple selection:text-white"
21
- x-cloak x-data="app" x-init="console.log('App initialized')">
21
+ x-cloak x-data="app" x-init="if(window.UILogger) window.UILogger.debug('App initialized')">
22
22
 
23
23
  <!-- Toast Notification -->
24
24
  <div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
@@ -389,6 +389,7 @@
389
389
  <!-- Scripts - Loading Order Matters! -->
390
390
  <!-- 1. Config & Utils (global helpers) -->
391
391
  <script src="js/config/constants.js"></script>
392
+ <script src="js/utils/ui-logger.js"></script><!-- Issue #183: Conditional logging utility -->
392
393
  <script src="js/utils.js"></script>
393
394
  <script src="js/utils/error-handler.js"></script>
394
395
  <script src="js/utils/account-actions.js"></script>
@@ -106,7 +106,7 @@ window.DashboardCharts.createDataset = function (label, data, color, canvas) {
106
106
  }
107
107
  }
108
108
  } catch (e) {
109
- console.warn("Failed to create gradient, using solid color fallback:", e);
109
+ if (window.UILogger) window.UILogger.debug("Gradient fallback:", e.message);
110
110
  gradient = null;
111
111
  }
112
112
 
@@ -149,7 +149,7 @@ window.DashboardCharts.updateCharts = function (component) {
149
149
  console.debug("Destroying existing quota chart from canvas property");
150
150
  try {
151
151
  canvas._chartInstance.destroy();
152
- } catch(e) { console.warn(e); }
152
+ } catch(e) { if (window.UILogger) window.UILogger.debug(e); }
153
153
  canvas._chartInstance = null;
154
154
  }
155
155
 
@@ -170,11 +170,11 @@ window.DashboardCharts.updateCharts = function (component) {
170
170
  }
171
171
 
172
172
  if (typeof Chart === "undefined") {
173
- console.warn("Chart.js not loaded");
173
+ if (window.UILogger) window.UILogger.warn("Chart.js not loaded");
174
174
  return;
175
175
  }
176
176
  if (!isCanvasReady(canvas)) {
177
- console.debug("quotaChart canvas not ready, skipping update");
177
+ if (window.UILogger) window.UILogger.debug("quotaChart canvas not ready, skipping update");
178
178
  return;
179
179
  }
180
180
 
@@ -319,12 +319,13 @@ window.DashboardCharts.updateCharts = function (component) {
319
319
  window.DashboardCharts.updateTrendChart = function (component) {
320
320
  // Prevent concurrent updates (fixes race condition on rapid toggling)
321
321
  if (_trendChartUpdateLock) {
322
- console.log("[updateTrendChart] Update already in progress, skipping");
322
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Update already in progress, skipping");
323
323
  return;
324
324
  }
325
325
  _trendChartUpdateLock = true;
326
326
 
327
- console.log("[updateTrendChart] Starting update...");
327
+ const logger = window.UILogger || console;
328
+ logger.debug("[updateTrendChart] Starting update...");
328
329
 
329
330
  const canvas = document.getElementById("usageTrendChart");
330
331
 
@@ -335,7 +336,7 @@ window.DashboardCharts.updateTrendChart = function (component) {
335
336
  try {
336
337
  canvas._chartInstance.stop();
337
338
  canvas._chartInstance.destroy();
338
- } catch(e) { console.warn(e); }
339
+ } catch(e) { if (window.UILogger) window.UILogger.debug(e); }
339
340
  canvas._chartInstance = null;
340
341
  }
341
342
 
@@ -359,17 +360,17 @@ window.DashboardCharts.updateTrendChart = function (component) {
359
360
 
360
361
  // Safety checks
361
362
  if (!canvas) {
362
- console.error("[updateTrendChart] Canvas not found in DOM!");
363
- _trendChartUpdateLock = false; // Release lock!
363
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not found in DOM");
364
+ _trendChartUpdateLock = false;
364
365
  return;
365
366
  }
366
367
  if (typeof Chart === "undefined") {
367
- console.error("[updateTrendChart] Chart.js not loaded");
368
- _trendChartUpdateLock = false; // Release lock!
368
+ if (window.UILogger) window.UILogger.warn("[updateTrendChart] Chart.js not loaded");
369
+ _trendChartUpdateLock = false;
369
370
  return;
370
371
  }
371
372
 
372
- console.log("[updateTrendChart] Canvas element:", {
373
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas element:", {
373
374
  exists: !!canvas,
374
375
  isConnected: canvas.isConnected,
375
376
  width: canvas.offsetWidth,
@@ -378,7 +379,7 @@ window.DashboardCharts.updateTrendChart = function (component) {
378
379
  });
379
380
 
380
381
  if (!isCanvasReady(canvas)) {
381
- console.error("[updateTrendChart] Canvas not ready!", {
382
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not ready", {
382
383
  isConnected: canvas.isConnected,
383
384
  width: canvas.offsetWidth,
384
385
  height: canvas.offsetHeight,
@@ -394,17 +395,17 @@ window.DashboardCharts.updateTrendChart = function (component) {
394
395
  ctx.clearRect(0, 0, canvas.width, canvas.height);
395
396
  }
396
397
  } catch (e) {
397
- console.warn("[updateTrendChart] Failed to clear canvas:", e);
398
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Failed to clear canvas:", e.message);
398
399
  }
399
400
 
400
- console.log(
401
+ if (window.UILogger) window.UILogger.debug(
401
402
  "[updateTrendChart] Canvas is ready, proceeding with chart creation"
402
403
  );
403
404
 
404
405
  // Use filtered history data based on time range
405
406
  const history = window.DashboardFilters.getFilteredHistoryData(component);
406
407
  if (!history || Object.keys(history).length === 0) {
407
- console.warn("No history data available for trend chart (after filtering)");
408
+ if (window.UILogger) window.UILogger.debug("No history data available for trend chart (after filtering)");
408
409
  component.hasFilteredTrendData = false;
409
410
  _trendChartUpdateLock = false;
410
411
  return;
@@ -50,7 +50,7 @@ window.DashboardFilters.loadPreferences = function(component) {
50
50
  component.selectedModels = prefs.selectedModels || {};
51
51
  }
52
52
  } catch (e) {
53
- console.error('Failed to load dashboard preferences:', e);
53
+ if (window.UILogger) window.UILogger.debug('Failed to load dashboard preferences:', e.message);
54
54
  }
55
55
  };
56
56
 
@@ -67,7 +67,7 @@ window.DashboardFilters.savePreferences = function(component) {
67
67
  selectedModels: component.selectedModels
68
68
  }));
69
69
  } catch (e) {
70
- console.error('Failed to save dashboard preferences:', e);
70
+ if (window.UILogger) window.UILogger.debug('Failed to save dashboard preferences:', e.message);
71
71
  }
72
72
  };
73
73
 
@@ -79,12 +79,12 @@ window.Components.logsViewer = () => ({
79
79
  this.$nextTick(() => this.scrollToBottom());
80
80
  }
81
81
  } catch (e) {
82
- console.error('Log parse error:', e);
82
+ if (window.UILogger) window.UILogger.debug('Log parse error:', e.message);
83
83
  }
84
84
  };
85
85
 
86
86
  this.eventSource.onerror = () => {
87
- console.warn('Log stream disconnected, reconnecting...');
87
+ if (window.UILogger) window.UILogger.debug('Log stream disconnected, reconnecting...');
88
88
  setTimeout(() => this.startLogStream(), 3000);
89
89
  };
90
90
  },
@@ -55,7 +55,7 @@ document.addEventListener('alpine:init', () => {
55
55
 
56
56
  // Check TTL
57
57
  if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
58
- console.log('Cache expired, skipping restoration');
58
+ if (window.UILogger) window.UILogger.debug('Cache expired, skipping restoration');
59
59
  localStorage.removeItem('ag_data_cache');
60
60
  return;
61
61
  }
@@ -70,11 +70,11 @@ document.addEventListener('alpine:init', () => {
70
70
  // Don't show loading on initial load if we have cache
71
71
  this.initialLoad = false;
72
72
  this.computeQuotaRows();
73
- console.log('Restored data from cache');
73
+ if (window.UILogger) window.UILogger.debug('Restored data from cache');
74
74
  }
75
75
  }
76
76
  } catch (e) {
77
- console.warn('Failed to load cache', e);
77
+ if (window.UILogger) window.UILogger.debug('Failed to load cache', e.message);
78
78
  }
79
79
  },
80
80
 
@@ -89,7 +89,7 @@ document.addEventListener('alpine:init', () => {
89
89
  };
90
90
  localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
91
91
  } catch (e) {
92
- console.warn('Failed to save cache', e);
92
+ if (window.UILogger) window.UILogger.debug('Failed to save cache', e.message);
93
93
  }
94
94
  },
95
95
 
@@ -127,6 +127,7 @@ document.addEventListener('alpine:init', () => {
127
127
 
128
128
  this.lastUpdated = new Date().toLocaleTimeString();
129
129
  } catch (error) {
130
+ // Keep error logging for actual fetch failures
130
131
  console.error('Fetch error:', error);
131
132
  const store = Alpine.store('global');
132
133
  store.showToast(store.t('connectionLost'), 'error');
@@ -237,6 +238,7 @@ document.addEventListener('alpine:init', () => {
237
238
  let minResetTime = null;
238
239
 
239
240
  this.accounts.forEach(acc => {
241
+ if (acc.enabled === false) return;
240
242
  if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
241
243
 
242
244
  const limit = acc.limits?.[modelId];
@@ -352,6 +354,7 @@ document.addEventListener('alpine:init', () => {
352
354
  const quotaInfo = [];
353
355
  // Use ALL accounts (no account filter)
354
356
  this.accounts.forEach(acc => {
357
+ if (acc.enabled === false) return;
355
358
  const limit = acc.limits?.[modelId];
356
359
  if (!limit) return;
357
360
  const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * UI Logger Utility
3
+ * Provides conditional logging for the web UI to reduce console spam in production.
4
+ * Wraps console methods and only outputs when debug mode is enabled.
5
+ *
6
+ * Usage:
7
+ * window.UILogger.debug('message') - Only logs if debug mode enabled
8
+ * window.UILogger.info('message') - Only logs if debug mode enabled
9
+ * window.UILogger.warn('message') - Always logs (important warnings)
10
+ * window.UILogger.error('message') - Always logs (errors should always be visible)
11
+ *
12
+ * Enable debug mode:
13
+ * - Set localStorage.setItem('ag_debug', 'true') in browser console
14
+ * - Or pass ?debug=true in URL
15
+ */
16
+
17
+ (function() {
18
+ 'use strict';
19
+
20
+ // Check if debug mode is enabled
21
+ function isDebugEnabled() {
22
+ // Check URL parameter
23
+ const urlParams = new URLSearchParams(window.location.search);
24
+ if (urlParams.get('debug') === 'true') {
25
+ return true;
26
+ }
27
+
28
+ // Check localStorage
29
+ try {
30
+ return localStorage.getItem('ag_debug') === 'true';
31
+ } catch (e) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ // Cache debug state (can be refreshed)
37
+ let debugEnabled = isDebugEnabled();
38
+
39
+ window.UILogger = {
40
+ /**
41
+ * Refresh debug state (call after changing localStorage)
42
+ */
43
+ refresh() {
44
+ debugEnabled = isDebugEnabled();
45
+ },
46
+
47
+ /**
48
+ * Enable debug mode
49
+ */
50
+ enableDebug() {
51
+ try {
52
+ localStorage.setItem('ag_debug', 'true');
53
+ debugEnabled = true;
54
+ console.info('[UILogger] Debug mode enabled. Refresh page to see all logs.');
55
+ } catch (e) {
56
+ console.warn('[UILogger] Could not save debug preference');
57
+ }
58
+ },
59
+
60
+ /**
61
+ * Disable debug mode
62
+ */
63
+ disableDebug() {
64
+ try {
65
+ localStorage.removeItem('ag_debug');
66
+ debugEnabled = false;
67
+ console.info('[UILogger] Debug mode disabled.');
68
+ } catch (e) {
69
+ // Ignore
70
+ }
71
+ },
72
+
73
+ /**
74
+ * Check if debug mode is enabled
75
+ * @returns {boolean}
76
+ */
77
+ isDebug() {
78
+ return debugEnabled;
79
+ },
80
+
81
+ /**
82
+ * Debug level - only logs if debug mode enabled
83
+ * Use for verbose debugging info (chart updates, cache operations, etc.)
84
+ */
85
+ debug(...args) {
86
+ if (debugEnabled) {
87
+ console.log('[DEBUG]', ...args);
88
+ }
89
+ },
90
+
91
+ /**
92
+ * Info level - only logs if debug mode enabled
93
+ * Use for informational messages that aren't errors
94
+ */
95
+ info(...args) {
96
+ if (debugEnabled) {
97
+ console.info('[INFO]', ...args);
98
+ }
99
+ },
100
+
101
+ /**
102
+ * Log level - alias for debug
103
+ */
104
+ log(...args) {
105
+ if (debugEnabled) {
106
+ console.log(...args);
107
+ }
108
+ },
109
+
110
+ /**
111
+ * Warn level - always logs
112
+ * Use for important warnings that users should see
113
+ * But suppress noisy/expected warnings unless in debug mode
114
+ */
115
+ warn(...args) {
116
+ // In production, only show critical warnings
117
+ // In debug mode, show all warnings
118
+ if (debugEnabled) {
119
+ console.warn(...args);
120
+ }
121
+ },
122
+
123
+ /**
124
+ * Warn level that always shows (for critical warnings)
125
+ */
126
+ warnAlways(...args) {
127
+ console.warn(...args);
128
+ },
129
+
130
+ /**
131
+ * Error level - always logs
132
+ * Errors should always be visible for debugging
133
+ */
134
+ error(...args) {
135
+ console.error(...args);
136
+ }
137
+ };
138
+
139
+ // Log initial state (only in debug mode)
140
+ if (debugEnabled) {
141
+ console.info('[UILogger] Debug mode is ON. Set localStorage ag_debug=false to disable.');
142
+ }
143
+ })();
package/src/auth/oauth.js CHANGED
@@ -137,22 +137,50 @@ export function extractCodeFromInput(input) {
137
137
  return { code: trimmed, state: null };
138
138
  }
139
139
 
140
+ /**
141
+ * Attempt to bind server to a specific port
142
+ * @param {http.Server} server - HTTP server instance
143
+ * @param {number} port - Port to bind to
144
+ * @returns {Promise<number>} Resolves with port on success, rejects on error
145
+ */
146
+ function tryBindPort(server, port) {
147
+ return new Promise((resolve, reject) => {
148
+ const onError = (err) => {
149
+ server.removeListener('listening', onSuccess);
150
+ reject(err);
151
+ };
152
+ const onSuccess = () => {
153
+ server.removeListener('error', onError);
154
+ resolve(port);
155
+ };
156
+ server.once('error', onError);
157
+ server.once('listening', onSuccess);
158
+ server.listen(port);
159
+ });
160
+ }
161
+
140
162
  /**
141
163
  * Start a local server to receive the OAuth callback
164
+ * Implements automatic port fallback for Windows compatibility (issue #176)
142
165
  * Returns an object with a promise and an abort function
143
166
  *
144
167
  * @param {string} expectedState - Expected state parameter for CSRF protection
145
168
  * @param {number} timeoutMs - Timeout in milliseconds (default 120000)
146
- * @returns {{promise: Promise<string>, abort: Function}} Object with promise and abort function
169
+ * @returns {{promise: Promise<string>, abort: Function, getPort: Function}} Object with promise, abort, and getPort functions
147
170
  */
148
171
  export function startCallbackServer(expectedState, timeoutMs = 120000) {
149
172
  let server = null;
150
173
  let timeoutId = null;
151
174
  let isAborted = false;
175
+ let actualPort = OAUTH_CONFIG.callbackPort;
176
+
177
+ const promise = new Promise(async (resolve, reject) => {
178
+ // Build list of ports to try: primary + fallbacks
179
+ const portsToTry = [OAUTH_CONFIG.callbackPort, ...(OAUTH_CONFIG.callbackFallbackPorts || [])];
180
+ const errors = [];
152
181
 
153
- const promise = new Promise((resolve, reject) => {
154
182
  server = http.createServer((req, res) => {
155
- const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`);
183
+ const url = new URL(req.url, `http://localhost:${actualPort}`);
156
184
 
157
185
  if (url.pathname !== '/oauth-callback') {
158
186
  res.writeHead(404);
@@ -232,17 +260,60 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
232
260
  resolve(code);
233
261
  });
234
262
 
235
- server.on('error', (err) => {
236
- if (err.code === 'EADDRINUSE') {
237
- reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`));
263
+ // Try ports with fallback logic (issue #176 - Windows EACCES fix)
264
+ let boundSuccessfully = false;
265
+ for (const port of portsToTry) {
266
+ try {
267
+ await tryBindPort(server, port);
268
+ actualPort = port;
269
+ boundSuccessfully = true;
270
+
271
+ if (port !== OAUTH_CONFIG.callbackPort) {
272
+ logger.warn(`[OAuth] Primary port ${OAUTH_CONFIG.callbackPort} unavailable, using fallback port ${port}`);
273
+ } else {
274
+ logger.info(`[OAuth] Callback server listening on port ${port}`);
275
+ }
276
+ break;
277
+ } catch (err) {
278
+ const errMsg = err.code === 'EACCES'
279
+ ? `Permission denied on port ${port}`
280
+ : err.code === 'EADDRINUSE'
281
+ ? `Port ${port} already in use`
282
+ : `Failed to bind port ${port}: ${err.message}`;
283
+ errors.push(errMsg);
284
+ logger.warn(`[OAuth] ${errMsg}`);
285
+ }
286
+ }
287
+
288
+ if (!boundSuccessfully) {
289
+ // All ports failed - provide helpful error message
290
+ const isWindows = process.platform === 'win32';
291
+ let errorMsg = `Failed to start OAuth callback server.\nTried ports: ${portsToTry.join(', ')}\n\nErrors:\n${errors.join('\n')}`;
292
+
293
+ if (isWindows) {
294
+ errorMsg += `\n
295
+ ================== WINDOWS TROUBLESHOOTING ==================
296
+ The default port range may be reserved by Hyper-V/WSL2/Docker.
297
+
298
+ Option 1: Use a custom port
299
+ Set OAUTH_CALLBACK_PORT=3456 in your environment or .env file
300
+
301
+ Option 2: Reset Windows NAT (run as Administrator)
302
+ net stop winnat && net start winnat
303
+
304
+ Option 3: Check reserved port ranges
305
+ netsh interface ipv4 show excludedportrange protocol=tcp
306
+
307
+ Option 4: Exclude port from reservation (run as Administrator)
308
+ netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1
309
+ ==============================================================`;
238
310
  } else {
239
- reject(err);
311
+ errorMsg += `\n\nTry setting a custom port: OAUTH_CALLBACK_PORT=3456`;
240
312
  }
241
- });
242
313
 
243
- server.listen(OAUTH_CONFIG.callbackPort, () => {
244
- logger.info(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
245
- });
314
+ reject(new Error(errorMsg));
315
+ return;
316
+ }
246
317
 
247
318
  // Timeout after specified duration
248
319
  timeoutId = setTimeout(() => {
@@ -266,7 +337,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
266
337
  }
267
338
  };
268
339
 
269
- return { promise, abort };
340
+ // Get actual port (useful when fallback is used)
341
+ const getPort = () => actualPort;
342
+
343
+ return { promise, abort, getPort };
270
344
  }
271
345
 
272
346
  /**
package/src/constants.js CHANGED
@@ -188,13 +188,19 @@ export function isThinkingModel(modelName) {
188
188
  }
189
189
 
190
190
  // Google OAuth configuration (from opencode-antigravity-auth)
191
+ // OAuth callback port - configurable via environment variable for Windows compatibility (issue #176)
192
+ // Windows may reserve ports in range 49152-65535 for Hyper-V/WSL2/Docker, causing EACCES errors
193
+ const OAUTH_CALLBACK_PORT = parseInt(process.env.OAUTH_CALLBACK_PORT || '51121', 10);
194
+ const OAUTH_CALLBACK_FALLBACK_PORTS = [51122, 51123, 51124, 51125, 51126];
195
+
191
196
  export const OAUTH_CONFIG = {
192
197
  clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
193
198
  clientSecret: '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',
194
199
  authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
195
200
  tokenUrl: 'https://oauth2.googleapis.com/token',
196
201
  userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo',
197
- callbackPort: 51121,
202
+ callbackPort: OAUTH_CALLBACK_PORT,
203
+ callbackFallbackPorts: OAUTH_CALLBACK_FALLBACK_PORTS,
198
204
  scopes: [
199
205
  'https://www.googleapis.com/auth/cloud-platform',
200
206
  'https://www.googleapis.com/auth/userinfo.email',
@@ -236,7 +242,7 @@ export const DEFAULT_PRESETS = [
236
242
  ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
237
243
  ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
238
244
  ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
239
- ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash-lite[1m]',
245
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
240
246
  CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-4-5-thinking',
241
247
  ENABLE_EXPERIMENTAL_MCP_CLI: 'true'
242
248
  }
@@ -249,7 +255,7 @@ export const DEFAULT_PRESETS = [
249
255
  ANTHROPIC_MODEL: 'gemini-3-pro-high[1m]',
250
256
  ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-high[1m]',
251
257
  ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3-flash[1m]',
252
- ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash-lite[1m]',
258
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash[1m]',
253
259
  CLAUDE_CODE_SUBAGENT_MODEL: 'gemini-3-flash[1m]',
254
260
  ENABLE_EXPERIMENTAL_MCP_CLI: 'true'
255
261
  }
@@ -18,7 +18,8 @@ import {
18
18
  hasGeminiHistory,
19
19
  hasUnsignedThinkingBlocks,
20
20
  needsThinkingRecovery,
21
- closeToolLoopForThinking
21
+ closeToolLoopForThinking,
22
+ cleanCacheControl
22
23
  } from './thinking-utils.js';
23
24
  import { logger } from '../utils/logger.js';
24
25
 
@@ -32,7 +33,13 @@ import { logger } from '../utils/logger.js';
32
33
  * @returns {Object} Request body for Cloud Code API
33
34
  */
34
35
  export function convertAnthropicToGoogle(anthropicRequest) {
35
- const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
36
+ // [CRITICAL FIX] Pre-clean all cache_control fields from messages (Issue #189)
37
+ // Claude Code CLI sends cache_control on various content blocks, but Cloud Code API
38
+ // rejects them with "Extra inputs are not permitted". Clean them proactively here
39
+ // before any other processing, following the pattern from Antigravity-Manager.
40
+ const messages = cleanCacheControl(anthropicRequest.messages || []);
41
+
42
+ const { system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
36
43
  const modelName = anthropicRequest.model || '';
37
44
  const modelFamily = getModelFamily(modelName);
38
45
  const isClaudeModel = modelFamily === 'claude';
@@ -7,6 +7,62 @@ 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
+ // Cache Control Cleaning (Issue #189)
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Remove cache_control fields from all content blocks in messages.
16
+ * This is a critical fix for Issue #189 where Claude Code CLI sends cache_control
17
+ * fields that the Cloud Code API rejects with "Extra inputs are not permitted".
18
+ *
19
+ * Inspired by Antigravity-Manager's clean_cache_control_from_messages() approach,
20
+ * this function proactively strips cache_control from ALL block types at the
21
+ * entry point of the conversion pipeline.
22
+ *
23
+ * @param {Array<Object>} messages - Array of messages in Anthropic format
24
+ * @returns {Array<Object>} Messages with cache_control fields removed
25
+ */
26
+ export function cleanCacheControl(messages) {
27
+ if (!Array.isArray(messages)) return messages;
28
+
29
+ let removedCount = 0;
30
+
31
+ const cleaned = messages.map(message => {
32
+ if (!message || typeof message !== 'object') return message;
33
+
34
+ // Handle string content (no cache_control possible)
35
+ if (typeof message.content === 'string') return message;
36
+
37
+ // Handle array content
38
+ if (!Array.isArray(message.content)) return message;
39
+
40
+ const cleanedContent = message.content.map(block => {
41
+ if (!block || typeof block !== 'object') return block;
42
+
43
+ // Check if cache_control exists before destructuring
44
+ if (block.cache_control === undefined) return block;
45
+
46
+ // Create a shallow copy without cache_control
47
+ const { cache_control, ...cleanBlock } = block;
48
+ removedCount++;
49
+
50
+ return cleanBlock;
51
+ });
52
+
53
+ return {
54
+ ...message,
55
+ content: cleanedContent
56
+ };
57
+ });
58
+
59
+ if (removedCount > 0) {
60
+ logger.debug(`[ThinkingUtils] Removed cache_control from ${removedCount} block(s)`);
61
+ }
62
+
63
+ return cleaned;
64
+ }
65
+
10
66
  /**
11
67
  * Check if a part is a thinking block
12
68
  * @param {Object} part - Content part to check
@@ -104,6 +160,38 @@ function sanitizeAnthropicThinkingBlock(block) {
104
160
  return block;
105
161
  }
106
162
 
163
+ /**
164
+ * Sanitize a text block by removing extra fields like cache_control.
165
+ * Only keeps: type, text
166
+ * @param {Object} block - Text block to sanitize
167
+ * @returns {Object} Sanitized text block
168
+ */
169
+ function sanitizeTextBlock(block) {
170
+ if (!block || block.type !== 'text') return block;
171
+
172
+ const sanitized = { type: 'text' };
173
+ if (block.text !== undefined) sanitized.text = block.text;
174
+ return sanitized;
175
+ }
176
+
177
+ /**
178
+ * Sanitize a tool_use block by removing extra fields like cache_control.
179
+ * Only keeps: type, id, name, input, thoughtSignature (for Gemini)
180
+ * @param {Object} block - Tool_use block to sanitize
181
+ * @returns {Object} Sanitized tool_use block
182
+ */
183
+ function sanitizeToolUseBlock(block) {
184
+ if (!block || block.type !== 'tool_use') return block;
185
+
186
+ const sanitized = { type: 'tool_use' };
187
+ if (block.id !== undefined) sanitized.id = block.id;
188
+ if (block.name !== undefined) sanitized.name = block.name;
189
+ if (block.input !== undefined) sanitized.input = block.input;
190
+ // Preserve thoughtSignature for Gemini models
191
+ if (block.thoughtSignature !== undefined) sanitized.thoughtSignature = block.thoughtSignature;
192
+ return sanitized;
193
+ }
194
+
107
195
  /**
108
196
  * Filter content array, keeping only thinking blocks with valid signatures.
109
197
  */
@@ -259,11 +347,13 @@ export function reorderAssistantContent(content) {
259
347
  // Sanitize thinking blocks to remove cache_control and other extra fields
260
348
  thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block));
261
349
  } else if (block.type === 'tool_use') {
262
- toolUseBlocks.push(block);
350
+ // Sanitize tool_use blocks to remove cache_control and other extra fields
351
+ toolUseBlocks.push(sanitizeToolUseBlock(block));
263
352
  } else if (block.type === 'text') {
264
353
  // Only keep text blocks with meaningful content
265
354
  if (block.text && block.text.trim().length > 0) {
266
- textBlocks.push(block);
355
+ // Sanitize text blocks to remove cache_control and other extra fields
356
+ textBlocks.push(sanitizeTextBlock(block));
267
357
  } else {
268
358
  droppedEmptyBlocks++;
269
359
  }