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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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 +39 -3
  10. package/dist/src/plugin/accounts.d.ts.map +1 -1
  11. package/dist/src/plugin/accounts.js +163 -17
  12. package/dist/src/plugin/accounts.js.map +1 -1
  13. package/dist/src/plugin/cli.d.ts +15 -12
  14. package/dist/src/plugin/cli.d.ts.map +1 -1
  15. package/dist/src/plugin/cli.js +40 -13
  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/request-helpers.d.ts.map +1 -1
  36. package/dist/src/plugin/request-helpers.js +61 -23
  37. package/dist/src/plugin/request-helpers.js.map +1 -1
  38. package/dist/src/plugin/request.d.ts +4 -1
  39. package/dist/src/plugin/request.d.ts.map +1 -1
  40. package/dist/src/plugin/request.js +60 -13
  41. package/dist/src/plugin/request.js.map +1 -1
  42. package/dist/src/plugin/rotation.d.ts +5 -4
  43. package/dist/src/plugin/rotation.d.ts.map +1 -1
  44. package/dist/src/plugin/rotation.js +35 -9
  45. package/dist/src/plugin/rotation.js.map +1 -1
  46. package/dist/src/plugin/search.d.ts +32 -0
  47. package/dist/src/plugin/search.d.ts.map +1 -0
  48. package/dist/src/plugin/search.js +197 -0
  49. package/dist/src/plugin/search.js.map +1 -0
  50. package/dist/src/plugin/storage.d.ts +2 -0
  51. package/dist/src/plugin/storage.d.ts.map +1 -1
  52. package/dist/src/plugin/storage.js +15 -2
  53. package/dist/src/plugin/storage.js.map +1 -1
  54. package/dist/src/plugin/transform/gemini.d.ts +1 -13
  55. package/dist/src/plugin/transform/gemini.d.ts.map +1 -1
  56. package/dist/src/plugin/transform/gemini.js +49 -12
  57. package/dist/src/plugin/transform/gemini.js.map +1 -1
  58. package/dist/src/plugin/transform/model-resolver.d.ts.map +1 -1
  59. package/dist/src/plugin/transform/model-resolver.js +4 -2
  60. package/dist/src/plugin/transform/model-resolver.js.map +1 -1
  61. package/dist/src/plugin/transform/types.d.ts +5 -0
  62. package/dist/src/plugin/transform/types.d.ts.map +1 -1
  63. package/dist/src/plugin/types.d.ts +1 -0
  64. package/dist/src/plugin/types.d.ts.map +1 -1
  65. package/dist/src/plugin/ui/ansi.d.ts +32 -0
  66. package/dist/src/plugin/ui/ansi.d.ts.map +1 -0
  67. package/dist/src/plugin/ui/ansi.js +52 -0
  68. package/dist/src/plugin/ui/ansi.js.map +1 -0
  69. package/dist/src/plugin/ui/auth-menu.d.ts +24 -0
  70. package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -0
  71. package/dist/src/plugin/ui/auth-menu.js +92 -0
  72. package/dist/src/plugin/ui/auth-menu.js.map +1 -0
  73. package/dist/src/plugin/ui/confirm.d.ts +2 -0
  74. package/dist/src/plugin/ui/confirm.d.ts.map +1 -0
  75. package/dist/src/plugin/ui/confirm.js +15 -0
  76. package/dist/src/plugin/ui/confirm.js.map +1 -0
  77. package/dist/src/plugin/ui/select.d.ts +14 -0
  78. package/dist/src/plugin/ui/select.d.ts.map +1 -0
  79. package/dist/src/plugin/ui/select.js +174 -0
  80. package/dist/src/plugin/ui/select.js.map +1 -0
  81. package/dist/src/plugin.d.ts.map +1 -1
  82. package/dist/src/plugin.js +317 -76
  83. package/dist/src/plugin.js.map +1 -1
  84. 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";
@@ -20,6 +21,7 @@ import { initDiskSignatureCache } from "./plugin/cache";
20
21
  import { createProactiveRefreshQueue } from "./plugin/refresh-queue";
21
22
  import { initLogger, createLogger } from "./plugin/logger";
22
23
  import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation";
24
+ import { executeSearch } from "./plugin/search";
23
25
  const MAX_OAUTH_ACCOUNTS = 10;
24
26
  const MAX_WARMUP_SESSIONS = 1000;
25
27
  const MAX_WARMUP_RETRIES = 2;
@@ -283,7 +285,7 @@ async function persistAccountPool(results, replaceAll = false) {
283
285
  },
284
286
  });
285
287
  }
286
- function retryAfterMsFromResponse(response) {
288
+ function retryAfterMsFromResponse(response, defaultRetryMs = 60_000) {
287
289
  const retryAfterMsHeader = response.headers.get("retry-after-ms");
288
290
  if (retryAfterMsHeader) {
289
291
  const parsed = Number.parseInt(retryAfterMsHeader, 10);
@@ -298,20 +300,54 @@ function retryAfterMsFromResponse(response) {
298
300
  return parsed * 1000;
299
301
  }
300
302
  }
301
- return 60_000;
303
+ return defaultRetryMs;
302
304
  }
305
+ /**
306
+ * Parse Go-style duration strings to milliseconds.
307
+ * Supports compound durations: "1h16m0.667s", "1.5s", "200ms", "5m30s"
308
+ *
309
+ * @param duration - Duration string in Go format
310
+ * @returns Duration in milliseconds, or null if parsing fails
311
+ */
303
312
  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;
313
+ // Handle simple formats first for backwards compatibility
314
+ const simpleMatch = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i);
315
+ if (simpleMatch) {
316
+ const value = parseFloat(simpleMatch[1]);
317
+ const unit = (simpleMatch[2] || "s").toLowerCase();
318
+ switch (unit) {
319
+ case "h": return value * 3600 * 1000;
320
+ case "m": return value * 60 * 1000;
321
+ case "s": return value * 1000;
322
+ case "ms": return value;
323
+ default: return value * 1000;
324
+ }
314
325
  }
326
+ // Parse compound Go-style durations: "1h16m0.667s", "5m30s", etc.
327
+ const compoundRegex = /(\d+(?:\.\d+)?)(h|m(?!s)|s|ms)/gi;
328
+ let totalMs = 0;
329
+ let matchFound = false;
330
+ let match;
331
+ while ((match = compoundRegex.exec(duration)) !== null) {
332
+ matchFound = true;
333
+ const value = parseFloat(match[1]);
334
+ const unit = match[2].toLowerCase();
335
+ switch (unit) {
336
+ case "h":
337
+ totalMs += value * 3600 * 1000;
338
+ break;
339
+ case "m":
340
+ totalMs += value * 60 * 1000;
341
+ break;
342
+ case "s":
343
+ totalMs += value * 1000;
344
+ break;
345
+ case "ms":
346
+ totalMs += value;
347
+ break;
348
+ }
349
+ }
350
+ return matchFound ? totalMs : null;
315
351
  }
316
352
  function extractRateLimitBodyInfo(body) {
317
353
  if (!body || typeof body !== "object") {
@@ -435,9 +471,10 @@ const emptyResponseAttempts = new Map();
435
471
  * @param accountIndex - The account index
436
472
  * @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude")
437
473
  * @param serverRetryAfterMs - Server-provided retry delay (if any)
474
+ * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000)
438
475
  * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window
439
476
  */
440
- function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
477
+ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs, maxBackoffMs = 60_000) {
441
478
  const now = Date.now();
442
479
  const stateKey = `${accountIndex}:${quotaKey}`;
443
480
  const previous = rateLimitStateByAccountQuota.get(stateKey);
@@ -445,7 +482,7 @@ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
445
482
  if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) {
446
483
  // Same rate limit event from concurrent request - don't increment
447
484
  const baseDelay = serverRetryAfterMs ?? 1000;
448
- const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), 60_000);
485
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs);
449
486
  return {
450
487
  attempt: previous.consecutive429,
451
488
  delayMs: Math.max(baseDelay, backoffDelay),
@@ -462,7 +499,7 @@ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
462
499
  quotaKey
463
500
  });
464
501
  const baseDelay = serverRetryAfterMs ?? 1000;
465
- const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60_000);
502
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs);
466
503
  return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false };
467
504
  }
468
505
  /**
@@ -540,6 +577,8 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
540
577
  // Load configuration from files and environment variables
541
578
  const config = loadConfig(directory);
542
579
  initRuntimeConfig(config);
580
+ // Cached getAuth function for tool access
581
+ let cachedGetAuth = null;
543
582
  // Initialize debug with config
544
583
  initializeDebug(config);
545
584
  // Initialize structured logger for TUI integration
@@ -616,11 +655,55 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
616
655
  }
617
656
  }
618
657
  };
658
+ // Create google_search tool with access to auth context
659
+ const googleSearchTool = tool({
660
+ 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.",
661
+ args: {
662
+ query: tool.schema.string().describe("The search query or question to answer using web search"),
663
+ 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."),
664
+ thinking: tool.schema.boolean().optional().default(true).describe("Enable deep thinking for more thorough analysis (default: true)"),
665
+ },
666
+ async execute(args, ctx) {
667
+ log.debug("Google Search tool called", { query: args.query, urlCount: args.urls?.length ?? 0 });
668
+ // Get current auth context
669
+ const auth = cachedGetAuth ? await cachedGetAuth() : null;
670
+ if (!auth || !isOAuthAuth(auth)) {
671
+ return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate.";
672
+ }
673
+ // Get access token and project ID
674
+ const parts = parseRefreshParts(auth.refresh);
675
+ const projectId = parts.managedProjectId || parts.projectId || "unknown";
676
+ // Ensure we have a valid access token
677
+ let accessToken = auth.access;
678
+ if (!accessToken || accessTokenExpired(auth)) {
679
+ try {
680
+ const refreshed = await refreshAccessToken(auth, client, providerId);
681
+ accessToken = refreshed?.access;
682
+ }
683
+ catch (error) {
684
+ return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`;
685
+ }
686
+ }
687
+ if (!accessToken) {
688
+ return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate.";
689
+ }
690
+ return executeSearch({
691
+ query: args.query,
692
+ urls: args.urls,
693
+ thinking: args.thinking,
694
+ }, accessToken, projectId, ctx.abort);
695
+ },
696
+ });
619
697
  return {
620
698
  event: eventHandler,
699
+ tool: {
700
+ google_search: googleSearchTool,
701
+ },
621
702
  auth: {
622
703
  provider: providerId,
623
704
  loader: async (getAuth, provider) => {
705
+ // Cache getAuth for tool access
706
+ cachedGetAuth = getAuth;
624
707
  const auth = await getAuth();
625
708
  // If OpenCode has no valid OAuth auth, clear any stale account storage
626
709
  if (!isOAuthAuth(auth)) {
@@ -704,10 +787,28 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
704
787
  throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted");
705
788
  }
706
789
  };
707
- // Helper to show toast without blocking on abort
790
+ // Use while(true) loop to handle rate limits with backoff
791
+ // This ensures we wait and retry when all accounts are rate-limited
792
+ const quietMode = config.quiet_mode;
793
+ // Debounce rate limit toasts to avoid spam (5s cooldown per message type)
794
+ const rateLimitToastCooldowns = new Map();
795
+ const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000;
796
+ // Helper to show toast without blocking on abort (respects quiet_mode)
708
797
  const showToast = async (message, variant) => {
798
+ if (quietMode)
799
+ return;
709
800
  if (abortSignal?.aborted)
710
801
  return;
802
+ // Debounce rate limit warnings to prevent toast spam
803
+ if (variant === "warning" && message.toLowerCase().includes("rate")) {
804
+ const toastKey = message.replace(/\d+/g, "X"); // Normalize numbers for grouping
805
+ const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0;
806
+ const now = Date.now();
807
+ if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) {
808
+ return; // Skip - shown recently
809
+ }
810
+ rateLimitToastCooldowns.set(toastKey, now);
811
+ }
711
812
  try {
712
813
  await client.tui.showToast({
713
814
  body: { message, variant },
@@ -717,9 +818,6 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
717
818
  // TUI may not be available
718
819
  }
719
820
  };
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
821
  const hasOtherAccountWithAntigravity = (currentAccount) => {
724
822
  if (family !== "gemini")
725
823
  return false;
@@ -735,8 +833,10 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
735
833
  }
736
834
  const account = accountManager.getCurrentOrNextForFamily(family, model, config.account_selection_strategy, 'antigravity', config.pid_offset_enabled);
737
835
  if (!account) {
836
+ const headerStyle = getHeaderStyleFromUrl(urlString, family);
837
+ const explicitQuota = isExplicitQuotaFromUrl(urlString);
738
838
  // All accounts are rate-limited - wait and retry
739
- const waitMs = accountManager.getMinWaitTimeForFamily(family, model) || 60_000;
839
+ const waitMs = accountManager.getMinWaitTimeForFamily(family, model, headerStyle, explicitQuota) || 60_000;
740
840
  const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000));
741
841
  pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`);
742
842
  if (isDebugEnabled()) {
@@ -773,8 +873,8 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
773
873
  rateLimitState: account.rateLimitResetTimes,
774
874
  });
775
875
  }
776
- // Show toast when switching to a different account (debounced, respects quiet mode)
777
- if (!quietMode && accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) {
876
+ // Show toast when switching to a different account (debounced, quiet_mode handled by showToast)
877
+ if (accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) {
778
878
  const accountLabel = account.email || `Account ${account.index + 1}`;
779
879
  await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
780
880
  accountManager.markToastShown(account.index);
@@ -928,6 +1028,9 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
928
1028
  let headerStyle = getHeaderStyleFromUrl(urlString, family);
929
1029
  const explicitQuota = isExplicitQuotaFromUrl(urlString);
930
1030
  pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`);
1031
+ if (account.fingerprint) {
1032
+ pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`);
1033
+ }
931
1034
  // Check if this header style is rate-limited for this account
932
1035
  if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) {
933
1036
  // Quota fallback: try alternate quota on same account (if enabled and not explicit)
@@ -936,9 +1039,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
936
1039
  if (alternateStyle && alternateStyle !== headerStyle) {
937
1040
  const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity";
938
1041
  const altQuotaName = alternateStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity";
939
- if (!quietMode) {
940
- await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning");
941
- }
1042
+ await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning");
942
1043
  headerStyle = alternateStyle;
943
1044
  pushDebug(`quota fallback: ${headerStyle}`);
944
1045
  }
@@ -955,15 +1056,20 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
955
1056
  let forceThinkingRecovery = false;
956
1057
  // Track if token was consumed (for hybrid strategy refund on error)
957
1058
  let tokenConsumed = false;
1059
+ // Track capacity retries per endpoint to prevent infinite loops
1060
+ let capacityRetryCount = 0;
1061
+ let lastEndpointIndex = -1;
958
1062
  for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
1063
+ // Reset capacity retry counter when switching to a new endpoint
1064
+ if (i !== lastEndpointIndex) {
1065
+ capacityRetryCount = 0;
1066
+ lastEndpointIndex = i;
1067
+ }
959
1068
  const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
960
1069
  try {
961
1070
  const prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, {
962
1071
  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,
1072
+ fingerprint: account.fingerprint,
967
1073
  });
968
1074
  const originalUrl = toUrlString(input);
969
1075
  const resolvedUrl = toUrlString(prepared.request);
@@ -979,6 +1085,12 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
979
1085
  projectId: projectContext.effectiveProjectId,
980
1086
  });
981
1087
  await runThinkingWarmup(prepared, projectContext.effectiveProjectId);
1088
+ if (config.request_jitter_max_ms > 0) {
1089
+ const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms);
1090
+ if (jitterMs > 0) {
1091
+ await sleep(jitterMs, abortSignal);
1092
+ }
1093
+ }
982
1094
  // Consume token for hybrid strategy
983
1095
  // Refunded later if request fails (429 or network error)
984
1096
  if (config.account_selection_strategy === 'hybrid') {
@@ -986,22 +1098,62 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
986
1098
  }
987
1099
  const response = await fetch(prepared.request, prepared.init);
988
1100
  pushDebug(`status=${response.status} ${response.statusText}`);
989
- // Handle 429 rate limit with improved logic
990
- if (response.status === 429) {
1101
+ // Handle 429 rate limit (or Service Overloaded) with improved logic
1102
+ if (response.status === 429 || response.status === 503 || response.status === 529) {
991
1103
  // Refund token on rate limit
992
1104
  if (tokenConsumed) {
993
1105
  getTokenTracker().refund(account.index);
994
1106
  tokenConsumed = false;
995
1107
  }
996
- const headerRetryMs = retryAfterMsFromResponse(response);
1108
+ const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000;
1109
+ const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000;
1110
+ const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs);
997
1111
  const bodyInfo = await extractRetryInfoFromBody(response);
998
1112
  const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs;
1113
+ // [Enhanced Parsing] Pass status to handling logic
1114
+ const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message, response.status);
1115
+ // STRATEGY 1: CAPACITY / SERVER ERROR (Transient)
1116
+ // Goal: Wait and Retry SAME Account. DO NOT LOCK.
1117
+ // We handle this FIRST to avoid calling getRateLimitBackoff() and polluting the global rate limit state for transient errors.
1118
+ if (rateLimitReason === "MODEL_CAPACITY_EXHAUSTED" || rateLimitReason === "SERVER_ERROR") {
1119
+ // Exponential backoff with jitter for capacity errors: 1s → 2s → 4s → 8s (max)
1120
+ // Matches Antigravity-Manager's ExponentialBackoff(1s, 8s)
1121
+ const baseDelayMs = 1000;
1122
+ const maxDelayMs = 8000;
1123
+ const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs);
1124
+ // Add ±10% jitter to prevent thundering herd
1125
+ const jitter = exponentialDelay * (0.9 + Math.random() * 0.2);
1126
+ const waitMs = Math.round(jitter);
1127
+ const waitSec = Math.round(waitMs / 1000);
1128
+ pushDebug(`Server busy (${rateLimitReason}) on account ${account.index}, exponential backoff ${waitMs}ms (attempt ${capacityRetryCount + 1})`);
1129
+ await showToast(`⏳ Server busy (${response.status}). Retrying in ${waitSec}s...`, "warning");
1130
+ await sleep(waitMs, abortSignal);
1131
+ // CRITICAL FIX: Decrement i so that the loop 'continue' retries the SAME endpoint index
1132
+ // (i++ in the loop will bring it back to the current index)
1133
+ // But limit retries to prevent infinite loops (Greptile feedback)
1134
+ if (capacityRetryCount < 3) {
1135
+ capacityRetryCount++;
1136
+ i -= 1;
1137
+ continue;
1138
+ }
1139
+ else {
1140
+ pushDebug(`Max capacity retries (3) exhausted for endpoint ${currentEndpoint}, regenerating fingerprint...`);
1141
+ // Regenerate fingerprint to get fresh device identity before trying next endpoint
1142
+ const newFingerprint = accountManager.regenerateAccountFingerprint(account.index);
1143
+ if (newFingerprint) {
1144
+ pushDebug(`Fingerprint regenerated for account ${account.index}`);
1145
+ }
1146
+ continue;
1147
+ }
1148
+ }
1149
+ // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN
1150
+ // Goal: Lock and Rotate (Standard Logic)
1151
+ // Only now do we call getRateLimitBackoff, which increments the global failure tracker
999
1152
  const quotaKey = headerStyleToQuotaKey(headerStyle, family);
1000
1153
  const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs);
1001
- const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message);
1154
+ // Calculate potential backoffs
1002
1155
  const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs);
1003
1156
  const effectiveDelayMs = Math.max(delayMs, smartBackoffMs);
1004
- const isCapacityExhausted = rateLimitReason === "MODEL_CAPACITY_EXHAUSTED";
1005
1157
  pushDebug(`429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`);
1006
1158
  if (bodyInfo.message) {
1007
1159
  pushDebug(`429 message=${bodyInfo.message}`);
@@ -1015,36 +1167,37 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1015
1167
  logRateLimitEvent(account.index, account.email, family, response.status, effectiveDelayMs, bodyInfo);
1016
1168
  await logResponseBody(debugContext, response, 429);
1017
1169
  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
1170
  const accountLabel = account.email || `Account ${account.index + 1}`;
1037
- if (attempt === 1) {
1171
+ // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same
1172
+ if (attempt === 1 && rateLimitReason !== "QUOTA_EXHAUSTED") {
1038
1173
  await showToast(`Rate limited. Quick retry in 1s...`, "warning");
1039
1174
  await sleep(FIRST_RETRY_DELAY_MS, abortSignal);
1175
+ // CacheFirst mode: wait for same account if within threshold (preserves prompt cache)
1176
+ if (config.scheduling_mode === 'cache_first') {
1177
+ const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000;
1178
+ // effectiveDelayMs is the backoff calculated for this account
1179
+ if (effectiveDelayMs <= maxCacheFirstWaitMs) {
1180
+ pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`);
1181
+ await showToast(`⏳ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, "info");
1182
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1183
+ await sleep(effectiveDelayMs, abortSignal);
1184
+ // Retry same endpoint after wait
1185
+ i -= 1;
1186
+ continue;
1187
+ }
1188
+ // Wait time exceeds threshold, fall through to switch
1189
+ pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`);
1190
+ }
1040
1191
  if (config.switch_on_first_rate_limit && accountCount > 1) {
1041
- accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1192
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);
1042
1193
  shouldSwitchAccount = true;
1043
1194
  break;
1044
1195
  }
1196
+ // Same endpoint retry for first RPM hit
1197
+ i -= 1;
1045
1198
  continue;
1046
1199
  }
1047
- accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1200
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);
1048
1201
  accountManager.requestSaveToDisk();
1049
1202
  // For Gemini, try prioritized Antigravity across ALL accounts first
1050
1203
  if (family === "gemini") {
@@ -1147,6 +1300,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1147
1300
  if (response.ok) {
1148
1301
  account.consecutiveFailures = 0;
1149
1302
  getHealthTracker().recordSuccess(account.index);
1303
+ accountManager.markAccountUsed(account.index);
1150
1304
  }
1151
1305
  logAntigravityDebugResponse(debugContext, response, {
1152
1306
  note: response.ok ? "Success" : `Error ${response.status}`,
@@ -1158,9 +1312,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1158
1312
  const cloned = response.clone();
1159
1313
  const bodyText = await cloned.text();
1160
1314
  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
- }
1315
+ await showToast("Context too long - use /compact to reduce size", "warning");
1164
1316
  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
1317
  return createSyntheticErrorResponse(errorMessage, prepared.requestedModel);
1166
1318
  }
@@ -1197,7 +1349,7 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1197
1349
  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
1350
  // Check for context errors and show appropriate toast
1199
1351
  const contextError = transformedResponse.headers.get("x-antigravity-context-error");
1200
- if (contextError && !quietMode) {
1352
+ if (contextError) {
1201
1353
  if (contextError === "prompt_too_long") {
1202
1354
  await showToast("Context too long - use /compact to reduce size, or trim your request", "warning");
1203
1355
  }
@@ -1289,18 +1441,82 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1289
1441
  const useManualMode = noBrowser || shouldSkipLocalServer();
1290
1442
  // Check for existing accounts and prompt user for login mode
1291
1443
  let startFresh = true;
1444
+ let refreshAccountIndex;
1292
1445
  const existingStorage = await loadAccounts();
1293
1446
  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");
1447
+ const now = Date.now();
1448
+ const existingAccounts = existingStorage.accounts.map((acc, idx) => {
1449
+ let status = 'unknown';
1450
+ const rateLimits = acc.rateLimitResetTimes;
1451
+ if (rateLimits) {
1452
+ const isRateLimited = Object.values(rateLimits).some((resetTime) => typeof resetTime === 'number' && resetTime > now);
1453
+ if (isRateLimited) {
1454
+ status = 'rate-limited';
1455
+ }
1456
+ else {
1457
+ status = 'active';
1458
+ }
1459
+ }
1460
+ else {
1461
+ status = 'active';
1462
+ }
1463
+ if (acc.coolingDownUntil && acc.coolingDownUntil > now) {
1464
+ status = 'rate-limited';
1465
+ }
1466
+ return {
1467
+ email: acc.email,
1468
+ index: idx,
1469
+ addedAt: acc.addedAt,
1470
+ lastUsed: acc.lastUsed,
1471
+ status,
1472
+ isCurrentAccount: idx === (existingStorage.activeIndex ?? 0),
1473
+ };
1474
+ });
1475
+ const menuResult = await promptLoginMode(existingAccounts);
1476
+ if (menuResult.mode === "cancel") {
1477
+ return {
1478
+ url: "",
1479
+ instructions: "Authentication cancelled",
1480
+ method: "auto",
1481
+ callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
1482
+ };
1483
+ }
1484
+ if (menuResult.deleteAccountIndex !== undefined) {
1485
+ const updatedAccounts = existingStorage.accounts.filter((_, idx) => idx !== menuResult.deleteAccountIndex);
1486
+ await saveAccounts({
1487
+ version: 3,
1488
+ accounts: updatedAccounts,
1489
+ activeIndex: 0,
1490
+ activeIndexByFamily: { claude: 0, gemini: 0 },
1491
+ });
1492
+ console.log("\nAccount deleted.\n");
1493
+ if (updatedAccounts.length > 0) {
1494
+ return {
1495
+ url: "",
1496
+ instructions: "Account deleted. Please run `opencode auth login` again to continue.",
1497
+ method: "auto",
1498
+ callback: async () => ({ type: "failed", error: "Account deleted - please re-run auth" }),
1499
+ };
1500
+ }
1501
+ }
1502
+ if (menuResult.refreshAccountIndex !== undefined) {
1503
+ refreshAccountIndex = menuResult.refreshAccountIndex;
1504
+ const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email;
1505
+ console.log(`\nRe-authenticating ${refreshEmail || 'account'}...\n`);
1506
+ startFresh = false;
1507
+ }
1508
+ if (menuResult.deleteAll) {
1509
+ await clearAccounts();
1510
+ console.log("\nAll accounts deleted.\n");
1511
+ startFresh = true;
1302
1512
  }
1303
1513
  else {
1514
+ startFresh = menuResult.mode === "fresh";
1515
+ }
1516
+ if (startFresh && !menuResult.deleteAll) {
1517
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
1518
+ }
1519
+ else if (!startFresh) {
1304
1520
  console.log("\nAdding to existing accounts.\n");
1305
1521
  }
1306
1522
  }
@@ -1394,7 +1610,6 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1394
1610
  break;
1395
1611
  }
1396
1612
  accounts.push(result);
1397
- // Show toast for successful account authentication
1398
1613
  try {
1399
1614
  await client.tui.showToast({
1400
1615
  body: {
@@ -1404,15 +1619,40 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1404
1619
  });
1405
1620
  }
1406
1621
  catch {
1407
- // TUI may not be available in CLI mode
1408
1622
  }
1409
1623
  try {
1410
- // Use startFresh only on first account, subsequent accounts always append
1411
- const isFirstAccount = accounts.length === 1;
1412
- await persistAccountPool([result], isFirstAccount && startFresh);
1624
+ if (refreshAccountIndex !== undefined) {
1625
+ const currentStorage = await loadAccounts();
1626
+ if (currentStorage) {
1627
+ const updatedAccounts = [...currentStorage.accounts];
1628
+ const parts = parseRefreshParts(result.refresh);
1629
+ if (parts.refreshToken) {
1630
+ updatedAccounts[refreshAccountIndex] = {
1631
+ email: result.email ?? updatedAccounts[refreshAccountIndex]?.email,
1632
+ refreshToken: parts.refreshToken,
1633
+ projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId,
1634
+ managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId,
1635
+ addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(),
1636
+ lastUsed: Date.now(),
1637
+ };
1638
+ await saveAccounts({
1639
+ version: 3,
1640
+ accounts: updatedAccounts,
1641
+ activeIndex: currentStorage.activeIndex,
1642
+ activeIndexByFamily: currentStorage.activeIndexByFamily,
1643
+ });
1644
+ }
1645
+ }
1646
+ }
1647
+ else {
1648
+ const isFirstAccount = accounts.length === 1;
1649
+ await persistAccountPool([result], isFirstAccount && startFresh);
1650
+ }
1413
1651
  }
1414
1652
  catch {
1415
- // ignore
1653
+ }
1654
+ if (refreshAccountIndex !== undefined) {
1655
+ break;
1416
1656
  }
1417
1657
  if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
1418
1658
  break;
@@ -1442,7 +1682,6 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1442
1682
  callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
1443
1683
  };
1444
1684
  }
1445
- // Get the actual deduplicated account count from storage
1446
1685
  let actualAccountCount = accounts.length;
1447
1686
  try {
1448
1687
  const finalStorage = await loadAccounts();
@@ -1451,11 +1690,13 @@ export const createAntigravityPlugin = (providerId) => async ({ client, director
1451
1690
  }
1452
1691
  }
1453
1692
  catch {
1454
- // Fall back to accounts.length if we can't read storage
1455
1693
  }
1694
+ const successMessage = refreshAccountIndex !== undefined
1695
+ ? `Token refreshed successfully.`
1696
+ : `Multi-account setup complete (${actualAccountCount} account(s)).`;
1456
1697
  return {
1457
1698
  url: "",
1458
- instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
1699
+ instructions: successMessage,
1459
1700
  method: "auto",
1460
1701
  callback: async () => primary,
1461
1702
  };