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/public/index.html CHANGED
@@ -9,8 +9,6 @@
9
9
 
10
10
  <!-- Libraries -->
11
11
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
12
- <!-- Alpine.js Plugins -->
13
- <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
14
12
  <!-- Alpine.js must be deferred so stores register their listeners first -->
15
13
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
16
14
 
@@ -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 {
@@ -203,127 +212,6 @@ window.Components.accountManager = () => ({
203
212
  newModelThreshold: 10
204
213
  },
205
214
 
206
- // Fingerprint Modal
207
- fingerprintModal: {
208
- email: '',
209
- current: null,
210
- history: [],
211
- loading: false,
212
- regenerating: false,
213
- restoring: false
214
- },
215
-
216
- async openFingerprintModal(account) {
217
- const store = Alpine.store('global');
218
- this.fingerprintModal = {
219
- email: account.email,
220
- current: null,
221
- history: [],
222
- loading: true,
223
- regenerating: false,
224
- restoring: false
225
- };
226
-
227
- document.getElementById('fingerprint_modal').showModal();
228
-
229
- try {
230
- const { response, newPassword } = await window.utils.request(
231
- `/api/accounts/${encodeURIComponent(account.email)}/fingerprint`,
232
- {},
233
- store.webuiPassword
234
- );
235
- if (newPassword) store.webuiPassword = newPassword;
236
-
237
- const data = await response.json();
238
- if (data.status === 'ok') {
239
- this.fingerprintModal.current = data.fingerprint;
240
- this.fingerprintModal.history = data.history;
241
- } else {
242
- store.showToast(data.error || 'Failed to fetch fingerprint', 'error');
243
- }
244
- } catch (e) {
245
- store.showToast('Error fetching fingerprint: ' + e.message, 'error');
246
- } finally {
247
- this.fingerprintModal.loading = false;
248
- }
249
- },
250
-
251
- async regenerateFingerprint() {
252
- const store = Alpine.store('global');
253
- const email = this.fingerprintModal.email;
254
- this.fingerprintModal.regenerating = true;
255
-
256
- try {
257
- const { response, newPassword } = await window.utils.request(
258
- `/api/accounts/${encodeURIComponent(email)}/fingerprint/regenerate`,
259
- { method: 'POST' },
260
- store.webuiPassword
261
- );
262
- if (newPassword) store.webuiPassword = newPassword;
263
-
264
- const data = await response.json();
265
- if (data.status === 'ok') {
266
- store.showToast('Fingerprint regenerated', 'success');
267
- // Refresh modal data
268
- this.fingerprintModal.current = data.fingerprint;
269
- // Fetch history again to show the old one moved to history
270
- await this.refreshFingerprintData(email);
271
- } else {
272
- throw new Error(data.error || 'Failed to regenerate');
273
- }
274
- } catch (e) {
275
- store.showToast('Regeneration failed: ' + e.message, 'error');
276
- } finally {
277
- this.fingerprintModal.regenerating = false;
278
- }
279
- },
280
-
281
- async restoreFingerprint(index) {
282
- const store = Alpine.store('global');
283
- const email = this.fingerprintModal.email;
284
- this.fingerprintModal.restoring = true;
285
-
286
- try {
287
- const { response, newPassword } = await window.utils.request(
288
- `/api/accounts/${encodeURIComponent(email)}/fingerprint/restore`,
289
- {
290
- method: 'POST',
291
- headers: { 'Content-Type': 'application/json' },
292
- body: JSON.stringify({ index })
293
- },
294
- store.webuiPassword
295
- );
296
- if (newPassword) store.webuiPassword = newPassword;
297
-
298
- const data = await response.json();
299
- if (data.status === 'ok') {
300
- store.showToast('Fingerprint restored', 'success');
301
- this.fingerprintModal.current = data.fingerprint;
302
- await this.refreshFingerprintData(email);
303
- } else {
304
- throw new Error(data.error || 'Failed to restore');
305
- }
306
- } catch (e) {
307
- store.showToast('Restore failed: ' + e.message, 'error');
308
- } finally {
309
- this.fingerprintModal.restoring = false;
310
- }
311
- },
312
-
313
- async refreshFingerprintData(email) {
314
- const store = Alpine.store('global');
315
- const { response, newPassword } = await window.utils.request(
316
- `/api/accounts/${encodeURIComponent(email)}/fingerprint`,
317
- {},
318
- store.webuiPassword
319
- );
320
- if (newPassword) store.webuiPassword = newPassword;
321
- const data = await response.json();
322
- if (data.status === 'ok') {
323
- this.fingerprintModal.history = data.history;
324
- }
325
- },
326
-
327
215
  openThresholdModal(account) {
328
216
  this.thresholdDialog = {
329
217
  email: account.email,
@@ -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,
@@ -88,7 +88,6 @@
88
88
  <th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('enabled')">Enabled</th>
89
89
  <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider flex-1 min-w-[200px]" x-text="$store.global.t('accountEmail')">Account (Email)</th>
90
90
  <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('source')">Source</th>
91
- <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24">Fingerprint</th>
92
91
  <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('tier')">Tier</th>
93
92
  <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('quota')">Quota</th>
94
93
  <th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
@@ -152,15 +151,6 @@
152
151
  x-text="acc.source || 'oauth'">
153
152
  </span>
154
153
  </td>
155
- <td class="py-4">
156
- <button class="p-2 rounded-lg hover:bg-space-800 text-gray-500 hover:text-neon-cyan transition-colors"
157
- @click="openFingerprintModal(acc)"
158
- :title="'View Fingerprint for ' + formatEmail(acc.email)">
159
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
160
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.2-2.873.571-4.241m2.823 10.36c.682-1.365 1.178-2.791 1.406-4.117m8.406 5.828a5.74 5.74 0 00-1.552-1.433" />
161
- </svg>
162
- </button>
163
- </td>
164
154
  <td class="py-4">
165
155
  <span :class="{
166
156
  'status-pill-ultra': acc.subscription?.tier === 'ultra',
@@ -647,143 +637,4 @@
647
637
  <button>close</button>
648
638
  </form>
649
639
  </dialog>
650
-
651
- <!-- Fingerprint Details Modal -->
652
- <dialog id="fingerprint_modal" class="modal backdrop-blur-sm">
653
- <div class="modal-box max-w-2xl w-full bg-space-900 border border-space-border text-gray-300 shadow-2xl p-6 relative">
654
- <!-- Close Button (Top Right) -->
655
- <form method="dialog" class="absolute top-4 right-4">
656
- <button class="btn btn-sm btn-circle btn-ghost text-gray-500 hover:text-white">
657
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
658
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
659
- </svg>
660
- </button>
661
- </form>
662
-
663
- <h3 class="font-bold text-xl text-white mb-2 flex items-center gap-2">
664
- <svg class="w-6 h-6 text-neon-cyan" fill="none" stroke="currentColor" viewBox="0 0 24 24">
665
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.2-2.873.571-4.241m2.823 10.36c.682-1.365 1.178-2.791 1.406-4.117m8.406 5.828a5.74 5.74 0 00-1.552-1.433" />
666
- </svg>
667
- <span>Device Fingerprint</span>
668
- </h3>
669
- <p class="text-sm text-gray-500 font-mono mb-6" x-text="Redact.email(fingerprintModal.email)"></p>
670
-
671
- <div x-show="fingerprintModal.loading" class="flex justify-center py-8">
672
- <span class="loading loading-spinner text-neon-cyan"></span>
673
- </div>
674
-
675
- <div x-show="!fingerprintModal.loading && fingerprintModal.current">
676
- <!-- Current Fingerprint -->
677
- <div class="bg-space-800/50 border border-space-border/30 rounded-lg p-4 mb-6 relative overflow-hidden">
678
- <div class="absolute top-0 right-0 p-2 opacity-10">
679
- <svg class="w-24 h-24 text-neon-cyan" fill="none" stroke="currentColor" viewBox="0 0 24 24">
680
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.2-2.873.571-4.241m2.823 10.36c.682-1.365 1.178-2.791 1.406-4.117m8.406 5.828a5.74 5.74 0 00-1.552-1.433" />
681
- </svg>
682
- </div>
683
-
684
- <div class="grid grid-cols-1 gap-3 relative z-10">
685
- <div>
686
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">Device ID</span>
687
- <div class="font-mono text-sm text-neon-cyan break-all" x-text="fingerprintModal.current?.deviceId"></div>
688
- </div>
689
- <div>
690
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">User Agent</span>
691
- <div class="font-mono text-xs text-gray-300 break-words bg-space-900/50 p-2 rounded border border-space-border/20 w-fit max-w-full"
692
- x-text="fingerprintModal.current?.userAgent"></div>
693
- </div>
694
- <div class="grid grid-cols-2 gap-4">
695
- <div>
696
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">Platform</span>
697
- <div class="font-mono text-xs text-gray-300"
698
- x-text="fingerprintModal.current?.clientMetadata?.platform || 'UNK'"></div>
699
- </div>
700
- <div>
701
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">Created</span>
702
- <div class="font-mono text-xs text-gray-400"
703
- x-text="new Date(fingerprintModal.current?.createdAt).toLocaleString()"></div>
704
- </div>
705
- </div>
706
- </div>
707
-
708
- <!-- Advanced Details (Collapsible) -->
709
- <div class="mt-4 pt-4 border-t border-space-border/20" x-data="{ expanded: false }">
710
- <button @click="expanded = !expanded"
711
- class="flex items-center gap-2 text-[10px] uppercase text-gray-500 font-bold tracking-wider hover:text-neon-cyan transition-colors w-full">
712
- <svg class="w-3 h-3 transition-transform" :class="{ 'rotate-90': expanded }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
713
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
714
- </svg>
715
- Advanced Details
716
- </button>
717
-
718
- <div x-show="expanded" x-collapse class="mt-3 grid grid-cols-1 gap-3 relative z-10">
719
- <div>
720
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">API Client</span>
721
- <div class="font-mono text-xs text-gray-300 break-words" x-text="fingerprintModal.current?.apiClient"></div>
722
- </div>
723
- <div>
724
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">Quota User</span>
725
- <div class="font-mono text-xs text-gray-300 break-all" x-text="fingerprintModal.current?.quotaUser"></div>
726
- </div>
727
- <div>
728
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">Session Token</span>
729
- <div class="font-mono text-xs text-gray-300 break-all" x-text="fingerprintModal.current?.sessionToken"></div>
730
- </div>
731
- <div>
732
- <span class="text-[10px] uppercase text-gray-500 font-bold tracking-wider">Metadata</span>
733
- <div class="font-mono text-[10px] text-gray-400 bg-space-900/50 p-2 rounded border border-space-border/20 mt-1">
734
- <div class="grid grid-cols-2 gap-x-4 gap-y-1">
735
- <div><span class="text-gray-600">OS Version:</span> <span class="text-gray-300" x-text="fingerprintModal.current?.clientMetadata?.osVersion"></span></div>
736
- <div><span class="text-gray-600">Arch:</span> <span class="text-gray-300" x-text="fingerprintModal.current?.clientMetadata?.arch"></span></div>
737
- <div><span class="text-gray-600">IDE:</span> <span class="text-gray-300" x-text="fingerprintModal.current?.clientMetadata?.ideType"></span></div>
738
- <div><span class="text-gray-600">Plugin:</span> <span class="text-gray-300" x-text="fingerprintModal.current?.clientMetadata?.pluginType"></span></div>
739
- </div>
740
- </div>
741
- </div>
742
- </div>
743
- </div>
744
- </div>
745
-
746
- <!-- Actions -->
747
- <div class="flex justify-end mb-6">
748
- <button class="btn btn-sm btn-outline border-neon-cyan text-neon-cyan hover:bg-neon-cyan hover:text-black gap-2"
749
- @click="regenerateFingerprint()"
750
- :disabled="fingerprintModal.regenerating">
751
- <svg class="w-4 h-4" :class="{ 'animate-spin': fingerprintModal.regenerating }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
752
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
753
- </svg>
754
- <span x-text="fingerprintModal.regenerating ? 'Regenerating...' : 'Regenerate Fingerprint'"></span>
755
- </button>
756
- </div>
757
-
758
- <!-- History -->
759
- <div x-show="fingerprintModal.history.length > 0">
760
- <h4 class="text-sm font-bold text-gray-400 mb-3 uppercase tracking-wider">History</h4>
761
- <div class="space-y-2 max-h-40 overflow-y-auto custom-scrollbar">
762
- <template x-for="(entry, index) in fingerprintModal.history" :key="entry.timestamp">
763
- <div class="flex items-center justify-between p-3 bg-space-800/30 border border-space-border/20 rounded hover:bg-space-800/50 transition-colors">
764
- <div class="flex-1 min-w-0 mr-4">
765
- <div class="flex items-center gap-2 mb-1">
766
- <span class="text-xs font-mono text-gray-400 truncate" x-text="entry.fingerprint.deviceId"></span>
767
- <span class="text-[10px] px-1.5 py-0.5 rounded bg-space-700 text-gray-500" x-text="entry.reason"></span>
768
- </div>
769
- <div class="text-[10px] text-gray-600 font-mono" x-text="new Date(entry.timestamp).toLocaleString()"></div>
770
- </div>
771
- <button class="btn btn-xs btn-ghost text-gray-500 hover:text-white"
772
- @click="restoreFingerprint(index)"
773
- :disabled="fingerprintModal.restoring"
774
- title="Restore this fingerprint">
775
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
776
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
777
- </svg>
778
- </button>
779
- </div>
780
- </template>
781
- </div>
782
- </div>
783
- </div>
784
- </div>
785
- <form method="dialog" class="modal-backdrop">
786
- <button>close</button>
787
- </form>
788
- </dialog>
789
640
  </div>
@@ -1750,6 +1750,64 @@
1750
1750
  </div>
1751
1751
  </div>
1752
1752
 
1753
+ <!-- Request Throttling -->
1754
+ <div class="space-y-4 pt-2 border-t border-space-border/10">
1755
+ <div class="flex items-center gap-2 mb-2">
1756
+ <span class="text-[10px] text-gray-500 font-bold uppercase tracking-widest">Request Throttling</span>
1757
+ <span class="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide rounded bg-amber-500/20 text-amber-400 border border-amber-500/40">Experimental</span>
1758
+ </div>
1759
+
1760
+ <!-- Request Throttling Card -->
1761
+ <div class="form-control bg-space-900/50 rounded-lg border transition-all duration-300 hover:border-amber-500/50"
1762
+ :class="serverConfig.requestThrottlingEnabled ? 'border-amber-500 bg-amber-500/10 shadow-[0_0_25px_rgba(245,158,11,0.2)] ring-1 ring-amber-500/30' : 'border-space-border/50'">
1763
+
1764
+ <!-- Toggle Row -->
1765
+ <div class="flex items-center justify-between p-4">
1766
+ <div class="flex flex-col gap-1">
1767
+ <span class="label-text font-medium transition-colors"
1768
+ :class="serverConfig.requestThrottlingEnabled ? 'text-amber-300' : 'text-gray-300'">Enable Request Throttling</span>
1769
+ <span class="text-xs transition-colors"
1770
+ :class="serverConfig.requestThrottlingEnabled ? 'text-amber-100/50' : 'text-gray-500'">Add delay before each Google API request to avoid rate limits.</span>
1771
+ </div>
1772
+ <label class="relative inline-flex items-center cursor-pointer">
1773
+ <input type="checkbox" class="sr-only peer"
1774
+ :checked="serverConfig.requestThrottlingEnabled"
1775
+ @change="toggleRequestThrottling($event.target.checked)"
1776
+ aria-label="Request throttling toggle">
1777
+ <div
1778
+ class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
1779
+ </div>
1780
+ </label>
1781
+ </div>
1782
+
1783
+ <!-- Delay Slider (only shown when enabled) -->
1784
+ <div x-show="serverConfig.requestThrottlingEnabled" x-transition x-collapse>
1785
+ <div class="border-t border-amber-500/20 px-4 pb-4 pt-3">
1786
+ <label class="flex justify-between items-center mb-2">
1787
+ <span class="text-xs text-amber-200/70">Delay per Request</span>
1788
+ <span class="font-mono text-amber-400 text-xs font-semibold"
1789
+ x-text="(serverConfig.requestDelayMs || 200) + 'ms'"></span>
1790
+ </label>
1791
+ <div class="flex gap-3 items-center">
1792
+ <input type="range" min="100" max="5000" step="100"
1793
+ class="custom-range custom-range-yellow flex-1"
1794
+ :value="serverConfig.requestDelayMs || 200"
1795
+ :style="`background-size: ${((serverConfig.requestDelayMs || 200) - 100) / 49}% 100%`"
1796
+ @input="toggleRequestDelayMs($event.target.value)"
1797
+ aria-label="Request delay slider">
1798
+ <input type="number" min="100" max="5000" step="100"
1799
+ class="input input-xs input-bordered w-20 bg-space-800/50 border-amber-500/30 text-white font-mono text-center"
1800
+ :value="serverConfig.requestDelayMs || 200"
1801
+ @change="toggleRequestDelayMs($event.target.value)"
1802
+ aria-label="Request delay value">
1803
+ </div>
1804
+ <p class="text-[9px] text-amber-100/40 mt-2 leading-tight">
1805
+ Milliseconds to wait before each API call (token refresh, quota check, messages, etc).</p>
1806
+ </div>
1807
+ </div>
1808
+ </div>
1809
+ </div>
1810
+
1753
1811
  <!-- Error Handling Tuning -->
1754
1812
  <div class="space-y-4 pt-2 border-t border-space-border/10">
1755
1813
  <div class="flex items-center gap-2 mb-2">
@@ -15,7 +15,7 @@ import {
15
15
  import { refreshAccessToken, parseRefreshParts, formatRefreshParts } from '../auth/oauth.js';
16
16
  import { getAuthStatus } from '../auth/database.js';
17
17
  import { logger } from '../utils/logger.js';
18
- import { isNetworkError } from '../utils/helpers.js';
18
+ import { isNetworkError, throttledFetch } from '../utils/helpers.js';
19
19
  import { onboardUser, getDefaultTierId } from './onboarding.js';
20
20
  import { parseTierId } from '../cloudcode/model-api.js';
21
21
 
@@ -228,7 +228,7 @@ export async function discoverProject(token, projectId = undefined) {
228
228
 
229
229
  for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
230
230
  try {
231
- const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
231
+ const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, {
232
232
  method: 'POST',
233
233
  headers: {
234
234
  'Authorization': `Bearer ${token}`,
@@ -15,6 +15,7 @@ import {
15
15
  resetAllRateLimits as resetLimits,
16
16
  markRateLimited as markLimited,
17
17
  markInvalid as markAccountInvalid,
18
+ clearInvalid as clearAccountInvalid,
18
19
  getMinWaitTimeMs as getMinWait,
19
20
  getRateLimitInfo as getLimitInfo,
20
21
  getConsecutiveFailures as getFailures,
@@ -34,7 +35,6 @@ import {
34
35
  } from './credentials.js';
35
36
  import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js';
36
37
  import { logger } from '../utils/logger.js';
37
- import { generateFingerprint, MAX_FINGERPRINT_HISTORY } from '../utils/fingerprint.js';
38
38
 
39
39
  export class AccountManager {
40
40
  #accounts = [];
@@ -138,6 +138,16 @@ export class AccountManager {
138
138
  return getInvalid(this.#accounts);
139
139
  }
140
140
 
141
+ /**
142
+ * Check if all enabled accounts are invalid (need user intervention).
143
+ * Unlike rate limits, invalid accounts won't self-recover — waiting is pointless.
144
+ * @returns {boolean} True if every enabled account is invalid
145
+ */
146
+ isAllAccountsInvalid() {
147
+ const enabled = this.#accounts.filter(a => a.enabled !== false);
148
+ return enabled.length > 0 && enabled.every(a => a.isInvalid);
149
+ }
150
+
141
151
  /**
142
152
  * Clear expired rate limits
143
153
  * @returns {number} Number of rate limits cleared
@@ -283,9 +293,19 @@ export class AccountManager {
283
293
  * Mark an account as invalid (credentials need re-authentication)
284
294
  * @param {string} email - Email of the account to mark
285
295
  * @param {string} reason - Reason for marking as invalid
296
+ * @param {string|null} verifyUrl - Optional verification URL (for 403 VALIDATION_REQUIRED)
297
+ */
298
+ markInvalid(email, reason = 'Unknown error', verifyUrl = null) {
299
+ markAccountInvalid(this.#accounts, email, reason, verifyUrl);
300
+ this.saveToDisk();
301
+ }
302
+
303
+ /**
304
+ * Clear invalid status for an account (after user completes verification)
305
+ * @param {string} email - Email of the account to clear
286
306
  */
287
- markInvalid(email, reason = 'Unknown error') {
288
- markAccountInvalid(this.#accounts, email, reason);
307
+ clearInvalid(email) {
308
+ clearAccountInvalid(this.#accounts, email);
289
309
  this.saveToDisk();
290
310
  }
291
311
 
@@ -434,11 +454,11 @@ export class AccountManager {
434
454
  modelRateLimits: a.modelRateLimits || {},
435
455
  isInvalid: a.isInvalid || false,
436
456
  invalidReason: a.invalidReason || null,
457
+ verifyUrl: a.verifyUrl || null,
437
458
  lastUsed: a.lastUsed,
438
459
  // Include quota threshold settings
439
460
  quotaThreshold: a.quotaThreshold,
440
- modelQuotaThresholds: a.modelQuotaThresholds || {},
441
- hasFingerprint: !!a.fingerprint
461
+ modelQuotaThresholds: a.modelQuotaThresholds || {}
442
462
  }))
443
463
  };
444
464
  }
@@ -503,88 +523,6 @@ export class AccountManager {
503
523
  getAllAccounts() {
504
524
  return this.#accounts;
505
525
  }
506
-
507
- /**
508
- * Regenerate fingerprint for an account
509
- * @param {string} email - Email of the account
510
- * @returns {Object|null} New fingerprint or null if account not found
511
- */
512
- regenerateFingerprint(email) {
513
- const account = this.#accounts.find(a => a.email === email);
514
- if (!account) return null;
515
-
516
- if (account.fingerprint) {
517
- const historyEntry = {
518
- fingerprint: account.fingerprint,
519
- timestamp: Date.now(),
520
- reason: 'regenerated'
521
- };
522
-
523
- if (!account.fingerprintHistory) {
524
- account.fingerprintHistory = [];
525
- }
526
-
527
- account.fingerprintHistory.unshift(historyEntry);
528
- if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
529
- account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
530
- }
531
- }
532
-
533
- account.fingerprint = generateFingerprint();
534
- this.saveToDisk();
535
- return account.fingerprint;
536
- }
537
-
538
- /**
539
- * Restore fingerprint from history
540
- * @param {string} email - Email of the account
541
- * @param {number} historyIndex - Index in history array (0 is most recent)
542
- * @returns {Object|null} Restored fingerprint or null
543
- */
544
- restoreFingerprint(email, historyIndex) {
545
- const account = this.#accounts.find(a => a.email === email);
546
- if (!account || !account.fingerprintHistory || !account.fingerprintHistory[historyIndex]) {
547
- return null;
548
- }
549
-
550
- const restoredEntry = account.fingerprintHistory[historyIndex];
551
-
552
- // Save current to history before restoring
553
- if (account.fingerprint) {
554
- const historyEntry = {
555
- fingerprint: account.fingerprint,
556
- timestamp: Date.now(),
557
- reason: 'restored'
558
- };
559
- // account.fingerprintHistory is guaranteed to exist if we are here
560
- account.fingerprintHistory.unshift(historyEntry);
561
- }
562
-
563
- // Remove the restored entry from history (shifted by 1 if we just unshifted)
564
- const removeIndex = account.fingerprint ? historyIndex + 1 : historyIndex;
565
- account.fingerprintHistory.splice(removeIndex, 1);
566
-
567
- // Restore
568
- account.fingerprint = { ...restoredEntry.fingerprint, createdAt: Date.now() };
569
-
570
- // Trim history again (since we added one)
571
- if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
572
- account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
573
- }
574
-
575
- this.saveToDisk();
576
- return account.fingerprint;
577
- }
578
-
579
- /**
580
- * Get fingerprint history
581
- * @param {string} email - Email of the account
582
- * @returns {Array} Array of fingerprint history entries
583
- */
584
- getFingerprintHistory(email) {
585
- const account = this.#accounts.find(a => a.email === email);
586
- return account ? (account.fingerprintHistory || []) : [];
587
- }
588
526
  }
589
527
 
590
528
  // Re-export CooldownReason for use by handlers
@@ -10,7 +10,7 @@ import {
10
10
  CLIENT_METADATA
11
11
  } from '../constants.js';
12
12
  import { logger } from '../utils/logger.js';
13
- import { sleep } from '../utils/helpers.js';
13
+ import { sleep, throttledFetch } from '../utils/helpers.js';
14
14
 
15
15
  /**
16
16
  * Get the default tier ID from allowed tiers list
@@ -64,7 +64,7 @@ export async function onboardUser(token, tierId, projectId = undefined, maxAttem
64
64
  for (const endpoint of ONBOARD_USER_ENDPOINTS) {
65
65
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
66
66
  try {
67
- const response = await fetch(`${endpoint}/v1internal:onboardUser`, {
67
+ const response = await throttledFetch(`${endpoint}/v1internal:onboardUser`, {
68
68
  method: 'POST',
69
69
  headers: {
70
70
  'Authorization': `Bearer ${token}`,