opencode-antigravity-auth 1.3.1 → 1.3.2-beta.1

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.
Files changed (88) hide show
  1. package/README.md +15 -0
  2. package/dist/src/antigravity/oauth.d.ts.map +1 -1
  3. package/dist/src/antigravity/oauth.js +10 -4
  4. package/dist/src/antigravity/oauth.js.map +1 -1
  5. package/dist/src/constants.d.ts +30 -3
  6. package/dist/src/constants.d.ts.map +1 -1
  7. package/dist/src/constants.js +80 -3
  8. package/dist/src/constants.js.map +1 -1
  9. package/dist/src/plugin/accounts.d.ts +42 -3
  10. package/dist/src/plugin/accounts.d.ts.map +1 -1
  11. package/dist/src/plugin/accounts.js +178 -20
  12. package/dist/src/plugin/accounts.js.map +1 -1
  13. package/dist/src/plugin/cli.d.ts +17 -12
  14. package/dist/src/plugin/cli.d.ts.map +1 -1
  15. package/dist/src/plugin/cli.js +56 -15
  16. package/dist/src/plugin/cli.js.map +1 -1
  17. package/dist/src/plugin/config/loader.d.ts.map +1 -1
  18. package/dist/src/plugin/config/loader.js +0 -13
  19. package/dist/src/plugin/config/loader.js.map +1 -1
  20. package/dist/src/plugin/config/schema.d.ts +38 -319
  21. package/dist/src/plugin/config/schema.d.ts.map +1 -1
  22. package/dist/src/plugin/config/schema.js +66 -27
  23. package/dist/src/plugin/config/schema.js.map +1 -1
  24. package/dist/src/plugin/core/streaming/transformer.d.ts.map +1 -1
  25. package/dist/src/plugin/core/streaming/transformer.js +37 -6
  26. package/dist/src/plugin/core/streaming/transformer.js.map +1 -1
  27. package/dist/src/plugin/core/streaming/types.d.ts.map +1 -1
  28. package/dist/src/plugin/debug.d.ts.map +1 -1
  29. package/dist/src/plugin/debug.js +14 -1
  30. package/dist/src/plugin/debug.js.map +1 -1
  31. package/dist/src/plugin/fingerprint.d.ts +70 -0
  32. package/dist/src/plugin/fingerprint.d.ts.map +1 -0
  33. package/dist/src/plugin/fingerprint.js +155 -0
  34. package/dist/src/plugin/fingerprint.js.map +1 -0
  35. package/dist/src/plugin/quota.d.ts +25 -0
  36. package/dist/src/plugin/quota.d.ts.map +1 -0
  37. package/dist/src/plugin/quota.js +192 -0
  38. package/dist/src/plugin/quota.js.map +1 -0
  39. package/dist/src/plugin/request-helpers.d.ts.map +1 -1
  40. package/dist/src/plugin/request-helpers.js +61 -23
  41. package/dist/src/plugin/request-helpers.js.map +1 -1
  42. package/dist/src/plugin/request.d.ts +4 -1
  43. package/dist/src/plugin/request.d.ts.map +1 -1
  44. package/dist/src/plugin/request.js +60 -13
  45. package/dist/src/plugin/request.js.map +1 -1
  46. package/dist/src/plugin/rotation.d.ts +5 -4
  47. package/dist/src/plugin/rotation.d.ts.map +1 -1
  48. package/dist/src/plugin/rotation.js +35 -9
  49. package/dist/src/plugin/rotation.js.map +1 -1
  50. package/dist/src/plugin/search.d.ts +32 -0
  51. package/dist/src/plugin/search.d.ts.map +1 -0
  52. package/dist/src/plugin/search.js +197 -0
  53. package/dist/src/plugin/search.js.map +1 -0
  54. package/dist/src/plugin/storage.d.ts +3 -0
  55. package/dist/src/plugin/storage.d.ts.map +1 -1
  56. package/dist/src/plugin/storage.js +15 -2
  57. package/dist/src/plugin/storage.js.map +1 -1
  58. package/dist/src/plugin/transform/gemini.d.ts +1 -13
  59. package/dist/src/plugin/transform/gemini.d.ts.map +1 -1
  60. package/dist/src/plugin/transform/gemini.js +49 -12
  61. package/dist/src/plugin/transform/gemini.js.map +1 -1
  62. package/dist/src/plugin/transform/model-resolver.d.ts.map +1 -1
  63. package/dist/src/plugin/transform/model-resolver.js +4 -2
  64. package/dist/src/plugin/transform/model-resolver.js.map +1 -1
  65. package/dist/src/plugin/transform/types.d.ts +5 -0
  66. package/dist/src/plugin/transform/types.d.ts.map +1 -1
  67. package/dist/src/plugin/types.d.ts +1 -0
  68. package/dist/src/plugin/types.d.ts.map +1 -1
  69. package/dist/src/plugin/ui/ansi.d.ts +32 -0
  70. package/dist/src/plugin/ui/ansi.d.ts.map +1 -0
  71. package/dist/src/plugin/ui/ansi.js +52 -0
  72. package/dist/src/plugin/ui/ansi.js.map +1 -0
  73. package/dist/src/plugin/ui/auth-menu.d.ts +29 -0
  74. package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -0
  75. package/dist/src/plugin/ui/auth-menu.js +97 -0
  76. package/dist/src/plugin/ui/auth-menu.js.map +1 -0
  77. package/dist/src/plugin/ui/confirm.d.ts +2 -0
  78. package/dist/src/plugin/ui/confirm.d.ts.map +1 -0
  79. package/dist/src/plugin/ui/confirm.js +15 -0
  80. package/dist/src/plugin/ui/confirm.js.map +1 -0
  81. package/dist/src/plugin/ui/select.d.ts +14 -0
  82. package/dist/src/plugin/ui/select.d.ts.map +1 -0
  83. package/dist/src/plugin/ui/select.js +174 -0
  84. package/dist/src/plugin/ui/select.js.map +1 -0
  85. package/dist/src/plugin.d.ts.map +1 -1
  86. package/dist/src/plugin.js +404 -78
  87. package/dist/src/plugin.js.map +1 -1
  88. package/package.json +4 -4
@@ -1,4 +1,5 @@
1
1
  import { exec } from "node:child_process";
2
+ import { tool } from "@opencode-ai/plugin";
2
3
  import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID } from "./constants";
3
4
  import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
4
5
  import { accessTokenExpired, isOAuthAuth, parseRefreshParts } from "./plugin/auth";
@@ -16,10 +17,12 @@ import { AccountManager, parseRateLimitReason, calculateBackoffMs } from "./plug
16
17
  import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
17
18
  import { loadConfig, initRuntimeConfig } from "./plugin/config";
18
19
  import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery";
20
+ import { checkAccountsQuota } from "./plugin/quota";
19
21
  import { initDiskSignatureCache } from "./plugin/cache";
20
22
  import { createProactiveRefreshQueue } from "./plugin/refresh-queue";
21
23
  import { initLogger, createLogger } from "./plugin/logger";
22
24
  import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation";
25
+ import { executeSearch } from "./plugin/search";
23
26
  const MAX_OAUTH_ACCOUNTS = 10;
24
27
  const MAX_WARMUP_SESSIONS = 1000;
25
28
  const MAX_WARMUP_RETRIES = 2;
@@ -31,6 +34,36 @@ function getCapacityBackoffDelay(consecutiveFailures) {
31
34
  const warmupAttemptedSessionIds = new Set();
32
35
  const warmupSucceededSessionIds = new Set();
33
36
  const log = createLogger("plugin");
37
+ // Module-level toast debounce to persist across requests (fixes toast spam)
38
+ const rateLimitToastCooldowns = new Map();
39
+ const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000;
40
+ const MAX_TOAST_COOLDOWN_ENTRIES = 100;
41
+ // Track if "all accounts rate-limited" toast was shown to prevent spam in while loop
42
+ let allAccountsRateLimitedToastShown = false;
43
+ function cleanupToastCooldowns() {
44
+ if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) {
45
+ const now = Date.now();
46
+ for (const [key, time] of rateLimitToastCooldowns) {
47
+ if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) {
48
+ rateLimitToastCooldowns.delete(key);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ function shouldShowRateLimitToast(message) {
54
+ cleanupToastCooldowns();
55
+ const toastKey = message.replace(/\d+/g, "X");
56
+ const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0;
57
+ const now = Date.now();
58
+ if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) {
59
+ return false;
60
+ }
61
+ rateLimitToastCooldowns.set(toastKey, now);
62
+ return true;
63
+ }
64
+ function resetAllAccountsRateLimitedToast() {
65
+ allAccountsRateLimitedToastShown = false;
66
+ }
34
67
  function trackWarmupAttempt(sessionId) {
35
68
  if (warmupSucceededSessionIds.has(sessionId)) {
36
69
  return false;
@@ -242,6 +275,7 @@ async function persistAccountPool(results, replaceAll = false) {
242
275
  managedProjectId: parts.managedProjectId,
243
276
  addedAt: now,
244
277
  lastUsed: now,
278
+ enabled: true,
245
279
  });
246
280
  continue;
247
281
  }
@@ -283,7 +317,7 @@ async function persistAccountPool(results, replaceAll = false) {
283
317
  },
284
318
  });
285
319
  }
286
- function retryAfterMsFromResponse(response) {
320
+ function retryAfterMsFromResponse(response, defaultRetryMs = 60_000) {
287
321
  const retryAfterMsHeader = response.headers.get("retry-after-ms");
288
322
  if (retryAfterMsHeader) {
289
323
  const parsed = Number.parseInt(retryAfterMsHeader, 10);
@@ -298,20 +332,54 @@ function retryAfterMsFromResponse(response) {
298
332
  return parsed * 1000;
299
333
  }
300
334
  }
301
- return 60_000;
335
+ return defaultRetryMs;
302
336
  }
337
+ /**
338
+ * Parse Go-style duration strings to milliseconds.
339
+ * Supports compound durations: "1h16m0.667s", "1.5s", "200ms", "5m30s"
340
+ *
341
+ * @param duration - Duration string in Go format
342
+ * @returns Duration in milliseconds, or null if parsing fails
343
+ */
303
344
  function parseDurationToMs(duration) {
304
- const match = duration.match(/^(\d+(?:\.\d+)?)(s|m|h)?$/i);
305
- if (!match)
306
- return null;
307
- const value = parseFloat(match[1]);
308
- const unit = (match[2] || "s").toLowerCase();
309
- switch (unit) {
310
- case "h": return value * 3600 * 1000;
311
- case "m": return value * 60 * 1000;
312
- case "s": return value * 1000;
313
- default: return value * 1000;
345
+ // Handle simple formats first for backwards compatibility
346
+ const simpleMatch = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i);
347
+ if (simpleMatch) {
348
+ const value = parseFloat(simpleMatch[1]);
349
+ const unit = (simpleMatch[2] || "s").toLowerCase();
350
+ switch (unit) {
351
+ case "h": return value * 3600 * 1000;
352
+ case "m": return value * 60 * 1000;
353
+ case "s": return value * 1000;
354
+ case "ms": return value;
355
+ default: return value * 1000;
356
+ }
357
+ }
358
+ // Parse compound Go-style durations: "1h16m0.667s", "5m30s", etc.
359
+ const compoundRegex = /(\d+(?:\.\d+)?)(h|m(?!s)|s|ms)/gi;
360
+ let totalMs = 0;
361
+ let matchFound = false;
362
+ let match;
363
+ while ((match = compoundRegex.exec(duration)) !== null) {
364
+ matchFound = true;
365
+ const value = parseFloat(match[1]);
366
+ const unit = match[2].toLowerCase();
367
+ switch (unit) {
368
+ case "h":
369
+ totalMs += value * 3600 * 1000;
370
+ break;
371
+ case "m":
372
+ totalMs += value * 60 * 1000;
373
+ break;
374
+ case "s":
375
+ totalMs += value * 1000;
376
+ break;
377
+ case "ms":
378
+ totalMs += value;
379
+ break;
380
+ }
314
381
  }
382
+ return matchFound ? totalMs : null;
315
383
  }
316
384
  function extractRateLimitBodyInfo(body) {
317
385
  if (!body || typeof body !== "object") {
@@ -435,9 +503,10 @@ const emptyResponseAttempts = new Map();
435
503
  * @param accountIndex - The account index
436
504
  * @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude")
437
505
  * @param serverRetryAfterMs - Server-provided retry delay (if any)
506
+ * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000)
438
507
  * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window
439
508
  */
440
- function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
509
+ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs, maxBackoffMs = 60_000) {
441
510
  const now = Date.now();
442
511
  const stateKey = `${accountIndex}:${quotaKey}`;
443
512
  const previous = rateLimitStateByAccountQuota.get(stateKey);
@@ -445,7 +514,7 @@ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
445
514
  if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) {
446
515
  // Same rate limit event from concurrent request - don't increment
447
516
  const baseDelay = serverRetryAfterMs ?? 1000;
448
- const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), 60_000);
517
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs);
449
518
  return {
450
519
  attempt: previous.consecutive429,
451
520
  delayMs: Math.max(baseDelay, backoffDelay),
@@ -462,7 +531,7 @@ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
462
531
  quotaKey
463
532
  });
464
533
  const baseDelay = serverRetryAfterMs ?? 1000;
465
- const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60_000);
534
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs);
466
535
  return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false };
467
536
  }
468
537
  /**
@@ -540,6 +609,8 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
540
609
  // Load configuration from files and environment variables
541
610
  const config = loadConfig(directory);
542
611
  initRuntimeConfig(config);
612
+ // Cached getAuth function for tool access
613
+ let cachedGetAuth = null;
543
614
  // Initialize debug with config
544
615
  initializeDebug(config);
545
616
  // Initialize structured logger for TUI integration
@@ -616,11 +687,55 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
616
687
  }
617
688
  }
618
689
  };
690
+ // Create google_search tool with access to auth context
691
+ const googleSearchTool = tool({
692
+ description: "Search the web using Google Search and analyze URLs. Returns real-time information from the internet with source citations. Use this when you need up-to-date information about current events, recent developments, or any topic that may have changed. You can also provide specific URLs to analyze. IMPORTANT: If the user mentions or provides any URLs in their query, you MUST extract those URLs and pass them in the 'urls' parameter for direct analysis.",
693
+ args: {
694
+ query: tool.schema.string().describe("The search query or question to answer using web search"),
695
+ urls: tool.schema.array(tool.schema.string()).optional().describe("List of specific URLs to fetch and analyze. IMPORTANT: Always extract and include any URLs mentioned by the user in their query here."),
696
+ thinking: tool.schema.boolean().optional().default(true).describe("Enable deep thinking for more thorough analysis (default: true)"),
697
+ },
698
+ async execute(args, ctx) {
699
+ log.debug("Google Search tool called", { query: args.query, urlCount: args.urls?.length ?? 0 });
700
+ // Get current auth context
701
+ const auth = cachedGetAuth ? await cachedGetAuth() : null;
702
+ if (!auth || !isOAuthAuth(auth)) {
703
+ return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate.";
704
+ }
705
+ // Get access token and project ID
706
+ const parts = parseRefreshParts(auth.refresh);
707
+ const projectId = parts.managedProjectId || parts.projectId || "unknown";
708
+ // Ensure we have a valid access token
709
+ let accessToken = auth.access;
710
+ if (!accessToken || accessTokenExpired(auth)) {
711
+ try {
712
+ const refreshed = await refreshAccessToken(auth, client, providerId);
713
+ accessToken = refreshed?.access;
714
+ }
715
+ catch (error) {
716
+ return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`;
717
+ }
718
+ }
719
+ if (!accessToken) {
720
+ return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate.";
721
+ }
722
+ return executeSearch({
723
+ query: args.query,
724
+ urls: args.urls,
725
+ thinking: args.thinking,
726
+ }, accessToken, projectId, ctx.abort);
727
+ },
728
+ });
619
729
  return {
620
730
  event: eventHandler,
731
+ tool: {
732
+ google_search: googleSearchTool,
733
+ },
621
734
  auth: {
622
735
  provider: providerId,
623
736
  loader: async (getAuth, provider) => {
737
+ // Cache getAuth for tool access
738
+ cachedGetAuth = getAuth;
624
739
  const auth = await getAuth();
625
740
  // If OpenCode has no valid OAuth auth, clear any stale account storage
626
741
  if (!isOAuthAuth(auth)) {
@@ -704,10 +819,20 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
704
819
  throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted");
705
820
  }
706
821
  };
707
- // Helper to show toast without blocking on abort
822
+ // Use while(true) loop to handle rate limits with backoff
823
+ // This ensures we wait and retry when all accounts are rate-limited
824
+ const quietMode = config.quiet_mode;
825
+ // Helper to show toast without blocking on abort (respects quiet_mode)
708
826
  const showToast = async (message, variant) => {
827
+ if (quietMode)
828
+ return;
709
829
  if (abortSignal?.aborted)
710
830
  return;
831
+ if (variant === "warning" && message.toLowerCase().includes("rate")) {
832
+ if (!shouldShowRateLimitToast(message)) {
833
+ return;
834
+ }
835
+ }
711
836
  try {
712
837
  await client.tui.showToast({
713
838
  body: { message, variant },
@@ -717,9 +842,6 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
717
842
  // TUI may not be available
718
843
  }
719
844
  };
720
- // Use while(true) loop to handle rate limits with backoff
721
- // This ensures we wait and retry when all accounts are rate-limited
722
- const quietMode = config.quiet_mode;
723
845
  const hasOtherAccountWithAntigravity = (currentAccount) => {
724
846
  if (family !== "gemini")
725
847
  return false;
@@ -735,8 +857,10 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
735
857
  }
736
858
  const account = accountManager.getCurrentOrNextForFamily(family, model, config.account_selection_strategy, 'antigravity', config.pid_offset_enabled);
737
859
  if (!account) {
860
+ const headerStyle = getHeaderStyleFromUrl(urlString, family);
861
+ const explicitQuota = isExplicitQuotaFromUrl(urlString);
738
862
  // All accounts are rate-limited - wait and retry
739
- const waitMs = accountManager.getMinWaitTimeForFamily(family, model) || 60_000;
863
+ const waitMs = accountManager.getMinWaitTimeForFamily(family, model, headerStyle, explicitQuota) || 60_000;
740
864
  const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000));
741
865
  pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`);
742
866
  if (isDebugEnabled()) {
@@ -758,11 +882,16 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
758
882
  `Quota resets in ${waitTimeFormatted}. ` +
759
883
  `Add more accounts with \`opencode auth login\` or wait and retry.`);
760
884
  }
761
- await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning");
885
+ if (!allAccountsRateLimitedToastShown) {
886
+ await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning");
887
+ allAccountsRateLimitedToastShown = true;
888
+ }
762
889
  // Wait for the rate-limit cooldown to expire, then retry
763
890
  await sleep(waitMs, abortSignal);
764
891
  continue;
765
892
  }
893
+ // Account is available - reset the toast flag
894
+ resetAllAccountsRateLimitedToast();
766
895
  pushDebug(`selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`);
767
896
  if (isDebugEnabled()) {
768
897
  logAccountContext("Selected", {
@@ -773,10 +902,13 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
773
902
  rateLimitState: account.rateLimitResetTimes,
774
903
  });
775
904
  }
776
- // Show toast when switching to a different account (debounced, respects quiet mode)
777
- if (!quietMode && accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) {
905
+ // Show toast when switching to a different account (debounced, quiet_mode handled by showToast)
906
+ if (accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) {
778
907
  const accountLabel = account.email || `Account ${account.index + 1}`;
779
- await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
908
+ // Calculate position among enabled accounts (not absolute index)
909
+ const enabledAccounts = accountManager.getEnabledAccounts();
910
+ const enabledPosition = enabledAccounts.findIndex(a => a.index === account.index) + 1;
911
+ await showToast(`Using ${accountLabel} (${enabledPosition}/${accountCount})`, "info");
780
912
  accountManager.markToastShown(account.index);
781
913
  }
782
914
  accountManager.requestSaveToDisk();
@@ -928,6 +1060,9 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
928
1060
  let headerStyle = getHeaderStyleFromUrl(urlString, family);
929
1061
  const explicitQuota = isExplicitQuotaFromUrl(urlString);
930
1062
  pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`);
1063
+ if (account.fingerprint) {
1064
+ pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`);
1065
+ }
931
1066
  // Check if this header style is rate-limited for this account
932
1067
  if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) {
933
1068
  // Quota fallback: try alternate quota on same account (if enabled and not explicit)
@@ -936,9 +1071,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
936
1071
  if (alternateStyle && alternateStyle !== headerStyle) {
937
1072
  const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity";
938
1073
  const altQuotaName = alternateStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity";
939
- if (!quietMode) {
940
- await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning");
941
- }
1074
+ await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning");
942
1075
  headerStyle = alternateStyle;
943
1076
  pushDebug(`quota fallback: ${headerStyle}`);
944
1077
  }
@@ -955,15 +1088,20 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
955
1088
  let forceThinkingRecovery = false;
956
1089
  // Track if token was consumed (for hybrid strategy refund on error)
957
1090
  let tokenConsumed = false;
1091
+ // Track capacity retries per endpoint to prevent infinite loops
1092
+ let capacityRetryCount = 0;
1093
+ let lastEndpointIndex = -1;
958
1094
  for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
1095
+ // Reset capacity retry counter when switching to a new endpoint
1096
+ if (i !== lastEndpointIndex) {
1097
+ capacityRetryCount = 0;
1098
+ lastEndpointIndex = i;
1099
+ }
959
1100
  const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
960
1101
  try {
961
1102
  const prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, {
962
1103
  claudeToolHardening: config.claude_tool_hardening,
963
- googleSearch: config.web_search ? {
964
- mode: config.web_search.default_mode,
965
- threshold: config.web_search.grounding_threshold
966
- } : undefined,
1104
+ fingerprint: account.fingerprint,
967
1105
  });
968
1106
  const originalUrl = toUrlString(input);
969
1107
  const resolvedUrl = toUrlString(prepared.request);
@@ -979,6 +1117,12 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
979
1117
  projectId: projectContext.effectiveProjectId,
980
1118
  });
981
1119
  await runThinkingWarmup(prepared, projectContext.effectiveProjectId);
1120
+ if (config.request_jitter_max_ms > 0) {
1121
+ const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms);
1122
+ if (jitterMs > 0) {
1123
+ await sleep(jitterMs, abortSignal);
1124
+ }
1125
+ }
982
1126
  // Consume token for hybrid strategy
983
1127
  // Refunded later if request fails (429 or network error)
984
1128
  if (config.account_selection_strategy === 'hybrid') {
@@ -986,22 +1130,62 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
986
1130
  }
987
1131
  const response = await fetch(prepared.request, prepared.init);
988
1132
  pushDebug(`status=${response.status} ${response.statusText}`);
989
- // Handle 429 rate limit with improved logic
990
- if (response.status === 429) {
1133
+ // Handle 429 rate limit (or Service Overloaded) with improved logic
1134
+ if (response.status === 429 || response.status === 503 || response.status === 529) {
991
1135
  // Refund token on rate limit
992
1136
  if (tokenConsumed) {
993
1137
  getTokenTracker().refund(account.index);
994
1138
  tokenConsumed = false;
995
1139
  }
996
- const headerRetryMs = retryAfterMsFromResponse(response);
1140
+ const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000;
1141
+ const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000;
1142
+ const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs);
997
1143
  const bodyInfo = await extractRetryInfoFromBody(response);
998
1144
  const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs;
1145
+ // [Enhanced Parsing] Pass status to handling logic
1146
+ const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message, response.status);
1147
+ // STRATEGY 1: CAPACITY / SERVER ERROR (Transient)
1148
+ // Goal: Wait and Retry SAME Account. DO NOT LOCK.
1149
+ // We handle this FIRST to avoid calling getRateLimitBackoff() and polluting the global rate limit state for transient errors.
1150
+ if (rateLimitReason === "MODEL_CAPACITY_EXHAUSTED" || rateLimitReason === "SERVER_ERROR") {
1151
+ // Exponential backoff with jitter for capacity errors: 1s → 2s → 4s → 8s (max)
1152
+ // Matches Antigravity-Manager's ExponentialBackoff(1s, 8s)
1153
+ const baseDelayMs = 1000;
1154
+ const maxDelayMs = 8000;
1155
+ const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs);
1156
+ // Add ±10% jitter to prevent thundering herd
1157
+ const jitter = exponentialDelay * (0.9 + Math.random() * 0.2);
1158
+ const waitMs = Math.round(jitter);
1159
+ const waitSec = Math.round(waitMs / 1000);
1160
+ pushDebug(`Server busy (${rateLimitReason}) on account ${account.index}, exponential backoff ${waitMs}ms (attempt ${capacityRetryCount + 1})`);
1161
+ await showToast(`⏳ Server busy (${response.status}). Retrying in ${waitSec}s...`, "warning");
1162
+ await sleep(waitMs, abortSignal);
1163
+ // CRITICAL FIX: Decrement i so that the loop 'continue' retries the SAME endpoint index
1164
+ // (i++ in the loop will bring it back to the current index)
1165
+ // But limit retries to prevent infinite loops (Greptile feedback)
1166
+ if (capacityRetryCount < 3) {
1167
+ capacityRetryCount++;
1168
+ i -= 1;
1169
+ continue;
1170
+ }
1171
+ else {
1172
+ pushDebug(`Max capacity retries (3) exhausted for endpoint ${currentEndpoint}, regenerating fingerprint...`);
1173
+ // Regenerate fingerprint to get fresh device identity before trying next endpoint
1174
+ const newFingerprint = accountManager.regenerateAccountFingerprint(account.index);
1175
+ if (newFingerprint) {
1176
+ pushDebug(`Fingerprint regenerated for account ${account.index}`);
1177
+ }
1178
+ continue;
1179
+ }
1180
+ }
1181
+ // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN
1182
+ // Goal: Lock and Rotate (Standard Logic)
1183
+ // Only now do we call getRateLimitBackoff, which increments the global failure tracker
999
1184
  const quotaKey = headerStyleToQuotaKey(headerStyle, family);
1000
1185
  const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs);
1001
- const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message);
1186
+ // Calculate potential backoffs
1002
1187
  const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs);
1003
1188
  const effectiveDelayMs = Math.max(delayMs, smartBackoffMs);
1004
- const isCapacityExhausted = rateLimitReason === "MODEL_CAPACITY_EXHAUSTED";
1005
1189
  pushDebug(`429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`);
1006
1190
  if (bodyInfo.message) {
1007
1191
  pushDebug(`429 message=${bodyInfo.message}`);
@@ -1015,36 +1199,37 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1015
1199
  logRateLimitEvent(account.index, account.email, family, response.status, effectiveDelayMs, bodyInfo);
1016
1200
  await logResponseBody(debugContext, response, 429);
1017
1201
  getHealthTracker().recordRateLimit(account.index);
1018
- if (isCapacityExhausted) {
1019
- const capacityBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs);
1020
- accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1021
- const backoffFormatted = formatWaitTime(capacityBackoffMs);
1022
- const failures = account.consecutiveFailures ?? 0;
1023
- pushDebug(`capacity exhausted on account ${account.index}, backoff=${capacityBackoffMs}ms (failure #${failures})`);
1024
- // Check if we can switch to another account (respects switch_on_first_rate_limit config)
1025
- if (config.switch_on_first_rate_limit && accountCount > 1) {
1026
- await showToast(`Server at capacity. Switching account in 1s...`, "warning");
1027
- await sleep(FIRST_RETRY_DELAY_MS, abortSignal);
1028
- shouldSwitchAccount = true;
1029
- break;
1030
- }
1031
- // No other accounts available or config disabled - wait the backoff
1032
- await showToast(`Server at capacity. Waiting ${backoffFormatted}... (attempt ${failures})`, "warning");
1033
- await sleep(capacityBackoffMs, abortSignal);
1034
- continue;
1035
- }
1036
1202
  const accountLabel = account.email || `Account ${account.index + 1}`;
1037
- if (attempt === 1) {
1203
+ // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same
1204
+ if (attempt === 1 && rateLimitReason !== "QUOTA_EXHAUSTED") {
1038
1205
  await showToast(`Rate limited. Quick retry in 1s...`, "warning");
1039
1206
  await sleep(FIRST_RETRY_DELAY_MS, abortSignal);
1207
+ // CacheFirst mode: wait for same account if within threshold (preserves prompt cache)
1208
+ if (config.scheduling_mode === 'cache_first') {
1209
+ const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000;
1210
+ // effectiveDelayMs is the backoff calculated for this account
1211
+ if (effectiveDelayMs <= maxCacheFirstWaitMs) {
1212
+ pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`);
1213
+ await showToast(`⏳ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, "info");
1214
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1215
+ await sleep(effectiveDelayMs, abortSignal);
1216
+ // Retry same endpoint after wait
1217
+ i -= 1;
1218
+ continue;
1219
+ }
1220
+ // Wait time exceeds threshold, fall through to switch
1221
+ pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`);
1222
+ }
1040
1223
  if (config.switch_on_first_rate_limit && accountCount > 1) {
1041
- accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1224
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);
1042
1225
  shouldSwitchAccount = true;
1043
1226
  break;
1044
1227
  }
1228
+ // Same endpoint retry for first RPM hit
1229
+ i -= 1;
1045
1230
  continue;
1046
1231
  }
1047
- accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1232
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);
1048
1233
  accountManager.requestSaveToDisk();
1049
1234
  // For Gemini, try prioritized Antigravity across ALL accounts first
1050
1235
  if (family === "gemini") {
@@ -1147,6 +1332,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1147
1332
  if (response.ok) {
1148
1333
  account.consecutiveFailures = 0;
1149
1334
  getHealthTracker().recordSuccess(account.index);
1335
+ accountManager.markAccountUsed(account.index);
1150
1336
  }
1151
1337
  logAntigravityDebugResponse(debugContext, response, {
1152
1338
  note: response.ok ? "Success" : `Error ${response.status}`,
@@ -1158,9 +1344,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1158
1344
  const cloned = response.clone();
1159
1345
  const bodyText = await cloned.text();
1160
1346
  if (bodyText.includes("Prompt is too long") || bodyText.includes("prompt_too_long")) {
1161
- if (!quietMode) {
1162
- await showToast("Context too long - use /compact to reduce size", "warning");
1163
- }
1347
+ await showToast("Context too long - use /compact to reduce size", "warning");
1164
1348
  const errorMessage = `[Antigravity Error] Context is too long for this model.\n\nPlease use /compact to reduce context size, then retry your request.\n\nAlternatively, you can:\n- Use /clear to start fresh\n- Use /undo to remove recent messages\n- Switch to a model with larger context window`;
1165
1349
  return createSyntheticErrorResponse(errorMessage, prepared.requestedModel);
1166
1350
  }
@@ -1197,7 +1381,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1197
1381
  const transformedResponse = await transformAntigravityResponse(response, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload, debugLines);
1198
1382
  // Check for context errors and show appropriate toast
1199
1383
  const contextError = transformedResponse.headers.get("x-antigravity-context-error");
1200
- if (contextError && !quietMode) {
1384
+ if (contextError) {
1201
1385
  if (contextError === "prompt_too_long") {
1202
1386
  await showToast("Context too long - use /compact to reduce size, or trim your request", "warning");
1203
1387
  }
@@ -1289,18 +1473,135 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1289
1473
  const useManualMode = noBrowser || shouldSkipLocalServer();
1290
1474
  // Check for existing accounts and prompt user for login mode
1291
1475
  let startFresh = true;
1476
+ let refreshAccountIndex;
1292
1477
  const existingStorage = await loadAccounts();
1293
1478
  if (existingStorage && existingStorage.accounts.length > 0) {
1294
- const existingAccounts = existingStorage.accounts.map((acc, idx) => ({
1295
- email: acc.email,
1296
- index: idx,
1297
- }));
1298
- const loginMode = await promptLoginMode(existingAccounts);
1299
- startFresh = loginMode === "fresh";
1300
- if (startFresh) {
1301
- console.log("\nStarting fresh - existing accounts will be replaced.\n");
1479
+ let menuResult;
1480
+ while (true) {
1481
+ const now = Date.now();
1482
+ const existingAccounts = existingStorage.accounts.map((acc, idx) => {
1483
+ let status = 'unknown';
1484
+ const rateLimits = acc.rateLimitResetTimes;
1485
+ if (rateLimits) {
1486
+ const isRateLimited = Object.values(rateLimits).some((resetTime) => typeof resetTime === 'number' && resetTime > now);
1487
+ if (isRateLimited) {
1488
+ status = 'rate-limited';
1489
+ }
1490
+ else {
1491
+ status = 'active';
1492
+ }
1493
+ }
1494
+ else {
1495
+ status = 'active';
1496
+ }
1497
+ if (acc.coolingDownUntil && acc.coolingDownUntil > now) {
1498
+ status = 'rate-limited';
1499
+ }
1500
+ return {
1501
+ email: acc.email,
1502
+ index: idx,
1503
+ addedAt: acc.addedAt,
1504
+ lastUsed: acc.lastUsed,
1505
+ status,
1506
+ isCurrentAccount: idx === (existingStorage.activeIndex ?? 0),
1507
+ enabled: acc.enabled !== false,
1508
+ };
1509
+ });
1510
+ menuResult = await promptLoginMode(existingAccounts);
1511
+ if (menuResult.mode === "check") {
1512
+ console.log("\nChecking quotas for all accounts...");
1513
+ const results = await checkAccountsQuota(existingStorage.accounts, client, providerId);
1514
+ for (const res of results) {
1515
+ const label = res.email || `Account ${res.index + 1}`;
1516
+ const disabledStr = res.disabled ? " (disabled)" : "";
1517
+ console.log(`\n${res.index + 1}. ${label}${disabledStr}`);
1518
+ if (res.status === "error") {
1519
+ console.log(` Error: ${res.error}`);
1520
+ continue;
1521
+ }
1522
+ if (!res.quota || Object.keys(res.quota.groups).length === 0) {
1523
+ console.log(" No quota information available.");
1524
+ if (res.quota?.error)
1525
+ console.log(` Error: ${res.quota.error}`);
1526
+ continue;
1527
+ }
1528
+ const printGrp = (name, group) => {
1529
+ if (!group)
1530
+ return;
1531
+ const remaining = typeof group.remainingFraction === 'number'
1532
+ ? `${Math.round(group.remainingFraction * 100)}%`
1533
+ : 'UNKNOWN';
1534
+ const resetStr = group.resetTime ? `, resets in ${formatWaitTime(Date.parse(group.resetTime) - Date.now())}` : '';
1535
+ console.log(` ${name}: ${remaining}${resetStr}`);
1536
+ };
1537
+ printGrp("Claude", res.quota.groups.claude);
1538
+ printGrp("Gemini 3 Pro", res.quota.groups["gemini-pro"]);
1539
+ printGrp("Gemini 3 Flash", res.quota.groups["gemini-flash"]);
1540
+ if (res.updatedAccount) {
1541
+ existingStorage.accounts[res.index] = res.updatedAccount;
1542
+ await saveAccounts(existingStorage);
1543
+ }
1544
+ }
1545
+ console.log("");
1546
+ continue;
1547
+ }
1548
+ if (menuResult.mode === "manage") {
1549
+ if (menuResult.toggleAccountIndex !== undefined) {
1550
+ const acc = existingStorage.accounts[menuResult.toggleAccountIndex];
1551
+ if (acc) {
1552
+ acc.enabled = acc.enabled === false;
1553
+ await saveAccounts(existingStorage);
1554
+ console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`);
1555
+ }
1556
+ }
1557
+ continue;
1558
+ }
1559
+ break;
1560
+ }
1561
+ if (menuResult.mode === "cancel") {
1562
+ return {
1563
+ url: "",
1564
+ instructions: "Authentication cancelled",
1565
+ method: "auto",
1566
+ callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
1567
+ };
1568
+ }
1569
+ if (menuResult.deleteAccountIndex !== undefined) {
1570
+ const updatedAccounts = existingStorage.accounts.filter((_, idx) => idx !== menuResult.deleteAccountIndex);
1571
+ await saveAccounts({
1572
+ version: 3,
1573
+ accounts: updatedAccounts,
1574
+ activeIndex: 0,
1575
+ activeIndexByFamily: { claude: 0, gemini: 0 },
1576
+ });
1577
+ console.log("\nAccount deleted.\n");
1578
+ if (updatedAccounts.length > 0) {
1579
+ return {
1580
+ url: "",
1581
+ instructions: "Account deleted. Please run `opencode auth login` again to continue.",
1582
+ method: "auto",
1583
+ callback: async () => ({ type: "failed", error: "Account deleted - please re-run auth" }),
1584
+ };
1585
+ }
1586
+ }
1587
+ if (menuResult.refreshAccountIndex !== undefined) {
1588
+ refreshAccountIndex = menuResult.refreshAccountIndex;
1589
+ const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email;
1590
+ console.log(`\nRe-authenticating ${refreshEmail || 'account'}...\n`);
1591
+ startFresh = false;
1592
+ }
1593
+ if (menuResult.deleteAll) {
1594
+ await clearAccounts();
1595
+ console.log("\nAll accounts deleted.\n");
1596
+ startFresh = true;
1302
1597
  }
1303
1598
  else {
1599
+ startFresh = menuResult.mode === "fresh";
1600
+ }
1601
+ if (startFresh && !menuResult.deleteAll) {
1602
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
1603
+ }
1604
+ else if (!startFresh) {
1304
1605
  console.log("\nAdding to existing accounts.\n");
1305
1606
  }
1306
1607
  }
@@ -1394,7 +1695,6 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1394
1695
  break;
1395
1696
  }
1396
1697
  accounts.push(result);
1397
- // Show toast for successful account authentication
1398
1698
  try {
1399
1699
  await client.tui.showToast({
1400
1700
  body: {
@@ -1404,15 +1704,40 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1404
1704
  });
1405
1705
  }
1406
1706
  catch {
1407
- // TUI may not be available in CLI mode
1408
1707
  }
1409
1708
  try {
1410
- // Use startFresh only on first account, subsequent accounts always append
1411
- const isFirstAccount = accounts.length === 1;
1412
- await persistAccountPool([result], isFirstAccount && startFresh);
1709
+ if (refreshAccountIndex !== undefined) {
1710
+ const currentStorage = await loadAccounts();
1711
+ if (currentStorage) {
1712
+ const updatedAccounts = [...currentStorage.accounts];
1713
+ const parts = parseRefreshParts(result.refresh);
1714
+ if (parts.refreshToken) {
1715
+ updatedAccounts[refreshAccountIndex] = {
1716
+ email: result.email ?? updatedAccounts[refreshAccountIndex]?.email,
1717
+ refreshToken: parts.refreshToken,
1718
+ projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId,
1719
+ managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId,
1720
+ addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(),
1721
+ lastUsed: Date.now(),
1722
+ };
1723
+ await saveAccounts({
1724
+ version: 3,
1725
+ accounts: updatedAccounts,
1726
+ activeIndex: currentStorage.activeIndex,
1727
+ activeIndexByFamily: currentStorage.activeIndexByFamily,
1728
+ });
1729
+ }
1730
+ }
1731
+ }
1732
+ else {
1733
+ const isFirstAccount = accounts.length === 1;
1734
+ await persistAccountPool([result], isFirstAccount && startFresh);
1735
+ }
1413
1736
  }
1414
1737
  catch {
1415
- // ignore
1738
+ }
1739
+ if (refreshAccountIndex !== undefined) {
1740
+ break;
1416
1741
  }
1417
1742
  if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
1418
1743
  break;
@@ -1442,7 +1767,6 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1442
1767
  callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
1443
1768
  };
1444
1769
  }
1445
- // Get the actual deduplicated account count from storage
1446
1770
  let actualAccountCount = accounts.length;
1447
1771
  try {
1448
1772
  const finalStorage = await loadAccounts();
@@ -1451,11 +1775,13 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1451
1775
  }
1452
1776
  }
1453
1777
  catch {
1454
- // Fall back to accounts.length if we can't read storage
1455
1778
  }
1779
+ const successMessage = refreshAccountIndex !== undefined
1780
+ ? `Token refreshed successfully.`
1781
+ : `Multi-account setup complete (${actualAccountCount} account(s)).`;
1456
1782
  return {
1457
1783
  url: "",
1458
- instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
1784
+ instructions: successMessage,
1459
1785
  method: "auto",
1460
1786
  callback: async () => primary,
1461
1787
  };