opencode-agy-auth 2.0.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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +745 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/antigravity/oauth.d.ts +31 -0
  8. package/dist/src/antigravity/oauth.d.ts.map +1 -0
  9. package/dist/src/antigravity/oauth.js +171 -0
  10. package/dist/src/antigravity/oauth.js.map +1 -0
  11. package/dist/src/constants.d.ts +155 -0
  12. package/dist/src/constants.d.ts.map +1 -0
  13. package/dist/src/constants.js +251 -0
  14. package/dist/src/constants.js.map +1 -0
  15. package/dist/src/hooks/auto-update-checker/cache.d.ts +3 -0
  16. package/dist/src/hooks/auto-update-checker/cache.d.ts.map +1 -0
  17. package/dist/src/hooks/auto-update-checker/cache.js +71 -0
  18. package/dist/src/hooks/auto-update-checker/cache.js.map +1 -0
  19. package/dist/src/hooks/auto-update-checker/checker.d.ts +16 -0
  20. package/dist/src/hooks/auto-update-checker/checker.d.ts.map +1 -0
  21. package/dist/src/hooks/auto-update-checker/checker.js +237 -0
  22. package/dist/src/hooks/auto-update-checker/checker.js.map +1 -0
  23. package/dist/src/hooks/auto-update-checker/constants.d.ts +9 -0
  24. package/dist/src/hooks/auto-update-checker/constants.d.ts.map +1 -0
  25. package/dist/src/hooks/auto-update-checker/constants.js +23 -0
  26. package/dist/src/hooks/auto-update-checker/constants.js.map +1 -0
  27. package/dist/src/hooks/auto-update-checker/index.d.ts +34 -0
  28. package/dist/src/hooks/auto-update-checker/index.d.ts.map +1 -0
  29. package/dist/src/hooks/auto-update-checker/index.js +125 -0
  30. package/dist/src/hooks/auto-update-checker/index.js.map +1 -0
  31. package/dist/src/hooks/auto-update-checker/types.d.ts +25 -0
  32. package/dist/src/hooks/auto-update-checker/types.d.ts.map +1 -0
  33. package/dist/src/hooks/auto-update-checker/types.js +1 -0
  34. package/dist/src/hooks/auto-update-checker/types.js.map +1 -0
  35. package/dist/src/plugin/accounts.d.ts +174 -0
  36. package/dist/src/plugin/accounts.d.ts.map +1 -0
  37. package/dist/src/plugin/accounts.js +1243 -0
  38. package/dist/src/plugin/accounts.js.map +1 -0
  39. package/dist/src/plugin/auth.d.ts +21 -0
  40. package/dist/src/plugin/auth.d.ts.map +1 -0
  41. package/dist/src/plugin/auth.js +46 -0
  42. package/dist/src/plugin/auth.js.map +1 -0
  43. package/dist/src/plugin/cache/index.d.ts +5 -0
  44. package/dist/src/plugin/cache/index.d.ts.map +1 -0
  45. package/dist/src/plugin/cache/index.js +5 -0
  46. package/dist/src/plugin/cache/index.js.map +1 -0
  47. package/dist/src/plugin/cache/signature-cache.d.ts +111 -0
  48. package/dist/src/plugin/cache/signature-cache.d.ts.map +1 -0
  49. package/dist/src/plugin/cache/signature-cache.js +375 -0
  50. package/dist/src/plugin/cache/signature-cache.js.map +1 -0
  51. package/dist/src/plugin/cache.d.ts +44 -0
  52. package/dist/src/plugin/cache.d.ts.map +1 -0
  53. package/dist/src/plugin/cache.js +200 -0
  54. package/dist/src/plugin/cache.js.map +1 -0
  55. package/dist/src/plugin/cli.d.ts +27 -0
  56. package/dist/src/plugin/cli.d.ts.map +1 -0
  57. package/dist/src/plugin/cli.js +130 -0
  58. package/dist/src/plugin/cli.js.map +1 -0
  59. package/dist/src/plugin/config/index.d.ts +16 -0
  60. package/dist/src/plugin/config/index.d.ts.map +1 -0
  61. package/dist/src/plugin/config/index.js +16 -0
  62. package/dist/src/plugin/config/index.js.map +1 -0
  63. package/dist/src/plugin/config/loader.d.ts +38 -0
  64. package/dist/src/plugin/config/loader.d.ts.map +1 -0
  65. package/dist/src/plugin/config/loader.js +204 -0
  66. package/dist/src/plugin/config/loader.js.map +1 -0
  67. package/dist/src/plugin/config/models.d.ts +27 -0
  68. package/dist/src/plugin/config/models.d.ts.map +1 -0
  69. package/dist/src/plugin/config/models.js +79 -0
  70. package/dist/src/plugin/config/models.js.map +1 -0
  71. package/dist/src/plugin/config/schema.d.ts +134 -0
  72. package/dist/src/plugin/config/schema.d.ts.map +1 -0
  73. package/dist/src/plugin/config/schema.js +445 -0
  74. package/dist/src/plugin/config/schema.js.map +1 -0
  75. package/dist/src/plugin/config/updater.d.ts +55 -0
  76. package/dist/src/plugin/config/updater.d.ts.map +1 -0
  77. package/dist/src/plugin/config/updater.js +133 -0
  78. package/dist/src/plugin/config/updater.js.map +1 -0
  79. package/dist/src/plugin/core/streaming/index.d.ts +3 -0
  80. package/dist/src/plugin/core/streaming/index.d.ts.map +1 -0
  81. package/dist/src/plugin/core/streaming/index.js +3 -0
  82. package/dist/src/plugin/core/streaming/index.js.map +1 -0
  83. package/dist/src/plugin/core/streaming/transformer.d.ts +10 -0
  84. package/dist/src/plugin/core/streaming/transformer.d.ts.map +1 -0
  85. package/dist/src/plugin/core/streaming/transformer.js +271 -0
  86. package/dist/src/plugin/core/streaming/transformer.js.map +1 -0
  87. package/dist/src/plugin/core/streaming/types.d.ts +27 -0
  88. package/dist/src/plugin/core/streaming/types.d.ts.map +1 -0
  89. package/dist/src/plugin/core/streaming/types.js +1 -0
  90. package/dist/src/plugin/core/streaming/types.js.map +1 -0
  91. package/dist/src/plugin/debug.d.ts +94 -0
  92. package/dist/src/plugin/debug.d.ts.map +1 -0
  93. package/dist/src/plugin/debug.js +418 -0
  94. package/dist/src/plugin/debug.js.map +1 -0
  95. package/dist/src/plugin/errors.d.ts +28 -0
  96. package/dist/src/plugin/errors.d.ts.map +1 -0
  97. package/dist/src/plugin/errors.js +42 -0
  98. package/dist/src/plugin/errors.js.map +1 -0
  99. package/dist/src/plugin/fingerprint.d.ts +71 -0
  100. package/dist/src/plugin/fingerprint.d.ts.map +1 -0
  101. package/dist/src/plugin/fingerprint.js +140 -0
  102. package/dist/src/plugin/fingerprint.js.map +1 -0
  103. package/dist/src/plugin/image-saver.d.ts +25 -0
  104. package/dist/src/plugin/image-saver.d.ts.map +1 -0
  105. package/dist/src/plugin/image-saver.js +86 -0
  106. package/dist/src/plugin/image-saver.js.map +1 -0
  107. package/dist/src/plugin/logger.d.ts +54 -0
  108. package/dist/src/plugin/logger.d.ts.map +1 -0
  109. package/dist/src/plugin/logger.js +120 -0
  110. package/dist/src/plugin/logger.js.map +1 -0
  111. package/dist/src/plugin/project.d.ts +33 -0
  112. package/dist/src/plugin/project.d.ts.map +1 -0
  113. package/dist/src/plugin/project.js +234 -0
  114. package/dist/src/plugin/project.js.map +1 -0
  115. package/dist/src/plugin/proxy.d.ts +2 -0
  116. package/dist/src/plugin/proxy.d.ts.map +1 -0
  117. package/dist/src/plugin/proxy.js +20 -0
  118. package/dist/src/plugin/proxy.js.map +1 -0
  119. package/dist/src/plugin/quota.d.ts +58 -0
  120. package/dist/src/plugin/quota.d.ts.map +1 -0
  121. package/dist/src/plugin/quota.js +430 -0
  122. package/dist/src/plugin/quota.js.map +1 -0
  123. package/dist/src/plugin/recovery/constants.d.ts +22 -0
  124. package/dist/src/plugin/recovery/constants.d.ts.map +1 -0
  125. package/dist/src/plugin/recovery/constants.js +43 -0
  126. package/dist/src/plugin/recovery/constants.js.map +1 -0
  127. package/dist/src/plugin/recovery/index.d.ts +12 -0
  128. package/dist/src/plugin/recovery/index.d.ts.map +1 -0
  129. package/dist/src/plugin/recovery/index.js +12 -0
  130. package/dist/src/plugin/recovery/index.js.map +1 -0
  131. package/dist/src/plugin/recovery/storage.d.ts +24 -0
  132. package/dist/src/plugin/recovery/storage.d.ts.map +1 -0
  133. package/dist/src/plugin/recovery/storage.js +354 -0
  134. package/dist/src/plugin/recovery/storage.js.map +1 -0
  135. package/dist/src/plugin/recovery/types.d.ts +116 -0
  136. package/dist/src/plugin/recovery/types.d.ts.map +1 -0
  137. package/dist/src/plugin/recovery/types.js +6 -0
  138. package/dist/src/plugin/recovery/types.js.map +1 -0
  139. package/dist/src/plugin/recovery.d.ts +61 -0
  140. package/dist/src/plugin/recovery.d.ts.map +1 -0
  141. package/dist/src/plugin/recovery.js +378 -0
  142. package/dist/src/plugin/recovery.js.map +1 -0
  143. package/dist/src/plugin/refresh-queue.d.ts +101 -0
  144. package/dist/src/plugin/refresh-queue.d.ts.map +1 -0
  145. package/dist/src/plugin/refresh-queue.js +248 -0
  146. package/dist/src/plugin/refresh-queue.js.map +1 -0
  147. package/dist/src/plugin/request-helpers.d.ts +306 -0
  148. package/dist/src/plugin/request-helpers.d.ts.map +1 -0
  149. package/dist/src/plugin/request-helpers.js +2476 -0
  150. package/dist/src/plugin/request-helpers.js.map +1 -0
  151. package/dist/src/plugin/request.d.ts +98 -0
  152. package/dist/src/plugin/request.d.ts.map +1 -0
  153. package/dist/src/plugin/request.js +1513 -0
  154. package/dist/src/plugin/request.js.map +1 -0
  155. package/dist/src/plugin/rotation.d.ts +169 -0
  156. package/dist/src/plugin/rotation.d.ts.map +1 -0
  157. package/dist/src/plugin/rotation.js +328 -0
  158. package/dist/src/plugin/rotation.js.map +1 -0
  159. package/dist/src/plugin/search.d.ts +32 -0
  160. package/dist/src/plugin/search.d.ts.map +1 -0
  161. package/dist/src/plugin/search.js +195 -0
  162. package/dist/src/plugin/search.js.map +1 -0
  163. package/dist/src/plugin/server.d.ts +23 -0
  164. package/dist/src/plugin/server.d.ts.map +1 -0
  165. package/dist/src/plugin/server.js +324 -0
  166. package/dist/src/plugin/server.js.map +1 -0
  167. package/dist/src/plugin/storage.d.ts +137 -0
  168. package/dist/src/plugin/storage.d.ts.map +1 -0
  169. package/dist/src/plugin/storage.js +588 -0
  170. package/dist/src/plugin/storage.js.map +1 -0
  171. package/dist/src/plugin/stores/signature-store.d.ts +5 -0
  172. package/dist/src/plugin/stores/signature-store.d.ts.map +1 -0
  173. package/dist/src/plugin/stores/signature-store.js +25 -0
  174. package/dist/src/plugin/stores/signature-store.js.map +1 -0
  175. package/dist/src/plugin/thinking-recovery.d.ts +90 -0
  176. package/dist/src/plugin/thinking-recovery.d.ts.map +1 -0
  177. package/dist/src/plugin/thinking-recovery.js +316 -0
  178. package/dist/src/plugin/thinking-recovery.js.map +1 -0
  179. package/dist/src/plugin/token.d.ts +19 -0
  180. package/dist/src/plugin/token.d.ts.map +1 -0
  181. package/dist/src/plugin/token.js +128 -0
  182. package/dist/src/plugin/token.js.map +1 -0
  183. package/dist/src/plugin/transform/claude.d.ts +82 -0
  184. package/dist/src/plugin/transform/claude.d.ts.map +1 -0
  185. package/dist/src/plugin/transform/claude.js +278 -0
  186. package/dist/src/plugin/transform/claude.js.map +1 -0
  187. package/dist/src/plugin/transform/cross-model-sanitizer.d.ts +35 -0
  188. package/dist/src/plugin/transform/cross-model-sanitizer.d.ts.map +1 -0
  189. package/dist/src/plugin/transform/cross-model-sanitizer.js +225 -0
  190. package/dist/src/plugin/transform/cross-model-sanitizer.js.map +1 -0
  191. package/dist/src/plugin/transform/gemini.d.ts +100 -0
  192. package/dist/src/plugin/transform/gemini.d.ts.map +1 -0
  193. package/dist/src/plugin/transform/gemini.js +504 -0
  194. package/dist/src/plugin/transform/gemini.js.map +1 -0
  195. package/dist/src/plugin/transform/index.d.ts +15 -0
  196. package/dist/src/plugin/transform/index.d.ts.map +1 -0
  197. package/dist/src/plugin/transform/index.js +14 -0
  198. package/dist/src/plugin/transform/index.js.map +1 -0
  199. package/dist/src/plugin/transform/model-resolver.d.ts +104 -0
  200. package/dist/src/plugin/transform/model-resolver.d.ts.map +1 -0
  201. package/dist/src/plugin/transform/model-resolver.js +409 -0
  202. package/dist/src/plugin/transform/model-resolver.js.map +1 -0
  203. package/dist/src/plugin/transform/types.d.ts +111 -0
  204. package/dist/src/plugin/transform/types.d.ts.map +1 -0
  205. package/dist/src/plugin/transform/types.js +1 -0
  206. package/dist/src/plugin/transform/types.js.map +1 -0
  207. package/dist/src/plugin/types.d.ts +97 -0
  208. package/dist/src/plugin/types.d.ts.map +1 -0
  209. package/dist/src/plugin/types.js +1 -0
  210. package/dist/src/plugin/types.js.map +1 -0
  211. package/dist/src/plugin/ui/ansi.d.ts +32 -0
  212. package/dist/src/plugin/ui/ansi.d.ts.map +1 -0
  213. package/dist/src/plugin/ui/ansi.js +52 -0
  214. package/dist/src/plugin/ui/ansi.js.map +1 -0
  215. package/dist/src/plugin/ui/auth-menu.d.ts +37 -0
  216. package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -0
  217. package/dist/src/plugin/ui/auth-menu.js +182 -0
  218. package/dist/src/plugin/ui/auth-menu.js.map +1 -0
  219. package/dist/src/plugin/ui/confirm.d.ts +2 -0
  220. package/dist/src/plugin/ui/confirm.d.ts.map +1 -0
  221. package/dist/src/plugin/ui/confirm.js +15 -0
  222. package/dist/src/plugin/ui/confirm.js.map +1 -0
  223. package/dist/src/plugin/ui/select.d.ts +23 -0
  224. package/dist/src/plugin/ui/select.d.ts.map +1 -0
  225. package/dist/src/plugin/ui/select.js +254 -0
  226. package/dist/src/plugin/ui/select.js.map +1 -0
  227. package/dist/src/plugin/version.d.ts +19 -0
  228. package/dist/src/plugin/version.d.ts.map +1 -0
  229. package/dist/src/plugin/version.js +74 -0
  230. package/dist/src/plugin/version.js.map +1 -0
  231. package/dist/src/plugin.d.ts +40 -0
  232. package/dist/src/plugin.d.ts.map +1 -0
  233. package/dist/src/plugin.js +3407 -0
  234. package/dist/src/plugin.js.map +1 -0
  235. package/package.json +73 -0
@@ -0,0 +1,3407 @@
1
+ import { exec } from "node:child_process";
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import { ANTIGRAVITY_DEFAULT_PROJECT_ID, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_ENDPOINT_PROD, ANTIGRAVITY_PROVIDER_ID, ANTIGRAVITY_VERSION_FALLBACK, getDefaultModelLimits, getAntigravityHeaders, updateLearnedLimit, } from "./constants";
4
+ import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
5
+ import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts, } from "./plugin/auth";
6
+ import { promptAddAnotherAccount, promptLoginMode, promptProjectId, pause, } from "./plugin/cli";
7
+ import { ensureProjectContext } from "./plugin/project";
8
+ import { startAntigravityDebugRequest, logAntigravityDebugResponse, logAccountContext, logRateLimitEvent, logRateLimitSnapshot, logResponseBody, logModelFamily, isDebugEnabled, getLogFilePath, initializeDebug, } from "./plugin/debug";
9
+ import { buildThinkingWarmupBody, isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request";
10
+ import { resolveModelWithTier } from "./plugin/transform/model-resolver";
11
+ import { isEmptyResponseBody, createSyntheticErrorResponse, filterThinkingFromHistory, parseContextLimitFromError, } from "./plugin/request-helpers";
12
+ import { EmptyResponseError } from "./plugin/errors";
13
+ import { AntigravityTokenRefreshError, refreshAccessToken, } from "./plugin/token";
14
+ import { startOAuthListener } from "./plugin/server";
15
+ import { clearAccounts, loadAccounts, saveAccounts, saveAccountsReplace, } from "./plugin/storage";
16
+ import { AccountManager, parseRateLimitReason, calculateBackoffMs, computeSoftQuotaCacheTtlMs, } from "./plugin/accounts";
17
+ import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
18
+ import { loadConfig, initRuntimeConfig, } from "./plugin/config";
19
+ import { createSessionRecoveryHook, getRecoverySuccessToast, } from "./plugin/recovery";
20
+ import { checkAccountsQuota, getGroupEarliestReset, getGroupMinFraction, readModelLimits, } from "./plugin/quota";
21
+ import { initDiskSignatureCache } from "./plugin/cache";
22
+ import { createProactiveRefreshQueue, } from "./plugin/refresh-queue";
23
+ import { initLogger, createLogger } from "./plugin/logger";
24
+ import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker, } from "./plugin/rotation";
25
+ import { initAntigravityVersion } from "./plugin/version";
26
+ import { executeSearch } from "./plugin/search";
27
+ import { configureProxy } from "./plugin/proxy";
28
+ import { json } from "zod";
29
+ // Configure proxy if environment variables are set
30
+ configureProxy();
31
+ const MAX_OAUTH_ACCOUNTS = 10;
32
+ const MAX_WARMUP_SESSIONS = 1000;
33
+ const MAX_WARMUP_RETRIES = 2;
34
+ const AUTO_COMPACT_TIMEOUT_MS = 15_000;
35
+ const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000];
36
+ function getCapacityBackoffDelay(consecutiveFailures) {
37
+ const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1);
38
+ return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000;
39
+ }
40
+ const warmupAttemptedSessionIds = new Set();
41
+ const warmupSucceededSessionIds = new Set();
42
+ // Track if this plugin instance is running in a child session (subagent, background task)
43
+ // Used to filter toasts based on toast_scope config
44
+ let isChildSession = false;
45
+ let childSessionParentID = undefined;
46
+ // Track the current OpenCode session ID for programmatic commands (e.g. auto-compact)
47
+ let currentOpenCodeSessionId = undefined;
48
+ const log = createLogger("plugin");
49
+ // Module-level toast debounce to persist across requests (fixes toast spam)
50
+ const rateLimitToastCooldowns = new Map();
51
+ const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000;
52
+ const MAX_TOAST_COOLDOWN_ENTRIES = 100;
53
+ // Track if "all accounts blocked" toasts were shown to prevent spam in while loop
54
+ let softQuotaToastShown = false;
55
+ let rateLimitToastShown = false;
56
+ // Module-level reference to AccountManager for access from auth.login
57
+ let activeAccountManager = null;
58
+ function cleanupToastCooldowns() {
59
+ if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) {
60
+ const now = Date.now();
61
+ for (const [key, time] of rateLimitToastCooldowns) {
62
+ if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) {
63
+ rateLimitToastCooldowns.delete(key);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ function shouldShowRateLimitToast(message) {
69
+ cleanupToastCooldowns();
70
+ const toastKey = message.replace(/\d+/g, "X");
71
+ const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0;
72
+ const now = Date.now();
73
+ if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) {
74
+ return false;
75
+ }
76
+ rateLimitToastCooldowns.set(toastKey, now);
77
+ return true;
78
+ }
79
+ async function runAutoCompactCommand(client, sessionId, directory, timeoutMs = AUTO_COMPACT_TIMEOUT_MS) {
80
+ let timeoutHandle;
81
+ const timeoutPromise = new Promise((_, reject) => {
82
+ timeoutHandle = setTimeout(() => {
83
+ reject(new Error("compact timeout"));
84
+ }, timeoutMs);
85
+ });
86
+ try {
87
+ await Promise.race([
88
+ client.session.command({
89
+ path: { id: sessionId },
90
+ body: { command: "compact", arguments: "" },
91
+ query: { directory },
92
+ }),
93
+ timeoutPromise,
94
+ ]);
95
+ }
96
+ finally {
97
+ if (timeoutHandle) {
98
+ clearTimeout(timeoutHandle);
99
+ }
100
+ }
101
+ }
102
+ async function createContextOverflowSyntheticResponse(input) {
103
+ const { client, directory, sessionId, requestedModel, effectiveModel, } = input;
104
+ const model = requestedModel || effectiveModel || "unknown";
105
+ if (sessionId) {
106
+ try {
107
+ await runAutoCompactCommand(client, sessionId, directory);
108
+ return createSyntheticErrorResponse("✅ Context was too long and has been automatically compacted.\n\nPlease **resend your last message** to continue.", model);
109
+ }
110
+ catch {
111
+ return createSyntheticErrorResponse("⚠️ Context too long and auto-compact failed.\n\nPlease run **/compact** manually, then resend your message.", model);
112
+ }
113
+ }
114
+ return createSyntheticErrorResponse("✅ Context was too long. Please run /compact manually, then resend your message.", model);
115
+ }
116
+ function resetAllAccountsBlockedToasts() {
117
+ softQuotaToastShown = false;
118
+ rateLimitToastShown = false;
119
+ }
120
+ const quotaRefreshInProgressByEmail = new Set();
121
+ let cachedModelLimits = getDefaultModelLimits();
122
+ async function triggerAsyncQuotaRefreshForAccount(accountManager, accountIndex, client, providerId, intervalMinutes, configDir) {
123
+ if (intervalMinutes <= 0)
124
+ return;
125
+ const accounts = accountManager.getAccounts();
126
+ const account = accounts[accountIndex];
127
+ if (!account || account.enabled === false)
128
+ return;
129
+ const accountKey = account.email ?? `idx-${accountIndex}`;
130
+ if (quotaRefreshInProgressByEmail.has(accountKey))
131
+ return;
132
+ const intervalMs = intervalMinutes * 60 * 1000;
133
+ const age = account.cachedQuotaUpdatedAt != null
134
+ ? Date.now() - account.cachedQuotaUpdatedAt
135
+ : Infinity;
136
+ if (age < intervalMs)
137
+ return;
138
+ quotaRefreshInProgressByEmail.add(accountKey);
139
+ try {
140
+ const accountsForCheck = accountManager.getAccountsForQuotaCheck();
141
+ const singleAccount = accountsForCheck[accountIndex];
142
+ if (!singleAccount) {
143
+ quotaRefreshInProgressByEmail.delete(accountKey);
144
+ return;
145
+ }
146
+ const results = await checkAccountsQuota([singleAccount], client, providerId);
147
+ if (results[0]?.status === "ok" && results[0]?.quota?.groups) {
148
+ accountManager.updateQuotaCache(accountIndex, results[0].quota.groups);
149
+ accountManager.requestSaveToDisk();
150
+ cachedModelLimits = await readModelLimits(configDir);
151
+ }
152
+ }
153
+ catch (err) {
154
+ log.debug(`quota-refresh-failed email=${accountKey}`, {
155
+ error: String(err),
156
+ });
157
+ }
158
+ finally {
159
+ quotaRefreshInProgressByEmail.delete(accountKey);
160
+ }
161
+ }
162
+ function trackWarmupAttempt(sessionId) {
163
+ if (warmupSucceededSessionIds.has(sessionId)) {
164
+ return false;
165
+ }
166
+ if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) {
167
+ const first = warmupAttemptedSessionIds.values().next().value;
168
+ if (first) {
169
+ warmupAttemptedSessionIds.delete(first);
170
+ warmupSucceededSessionIds.delete(first);
171
+ }
172
+ }
173
+ const attempts = getWarmupAttemptCount(sessionId);
174
+ if (attempts >= MAX_WARMUP_RETRIES) {
175
+ return false;
176
+ }
177
+ warmupAttemptedSessionIds.add(sessionId);
178
+ return true;
179
+ }
180
+ function getWarmupAttemptCount(sessionId) {
181
+ return warmupAttemptedSessionIds.has(sessionId) ? 1 : 0;
182
+ }
183
+ function markWarmupSuccess(sessionId) {
184
+ warmupSucceededSessionIds.add(sessionId);
185
+ if (warmupSucceededSessionIds.size >= MAX_WARMUP_SESSIONS) {
186
+ const first = warmupSucceededSessionIds.values().next().value;
187
+ if (first)
188
+ warmupSucceededSessionIds.delete(first);
189
+ }
190
+ }
191
+ function clearWarmupAttempt(sessionId) {
192
+ warmupAttemptedSessionIds.delete(sessionId);
193
+ }
194
+ function isWSL() {
195
+ if (process.platform !== "linux")
196
+ return false;
197
+ try {
198
+ const { readFileSync } = require("node:fs");
199
+ const release = readFileSync("/proc/version", "utf8").toLowerCase();
200
+ return release.includes("microsoft") || release.includes("wsl");
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ function isWSL2() {
207
+ if (!isWSL())
208
+ return false;
209
+ try {
210
+ const { readFileSync } = require("node:fs");
211
+ const version = readFileSync("/proc/version", "utf8").toLowerCase();
212
+ return version.includes("wsl2") || version.includes("microsoft-standard");
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ function isRemoteEnvironment() {
219
+ if (process.env.SSH_CLIENT ||
220
+ process.env.SSH_TTY ||
221
+ process.env.SSH_CONNECTION) {
222
+ return true;
223
+ }
224
+ if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
225
+ return true;
226
+ }
227
+ if (process.platform === "linux" &&
228
+ !process.env.DISPLAY &&
229
+ !process.env.WAYLAND_DISPLAY &&
230
+ !isWSL()) {
231
+ return true;
232
+ }
233
+ return false;
234
+ }
235
+ function shouldSkipLocalServer() {
236
+ return isWSL2() || isRemoteEnvironment();
237
+ }
238
+ async function openBrowser(url) {
239
+ try {
240
+ if (process.platform === "darwin") {
241
+ exec(`open "${url}"`);
242
+ return true;
243
+ }
244
+ if (process.platform === "win32") {
245
+ exec(`start "" "${url}"`);
246
+ return true;
247
+ }
248
+ if (isWSL()) {
249
+ try {
250
+ exec(`wslview "${url}"`);
251
+ return true;
252
+ }
253
+ catch { }
254
+ }
255
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
256
+ return false;
257
+ }
258
+ exec(`xdg-open "${url}"`);
259
+ return true;
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ function decodeEscapedText(input) {
266
+ return input
267
+ .replace(/&amp;/g, "&")
268
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
269
+ }
270
+ function normalizeGoogleVerificationUrl(rawUrl) {
271
+ const normalized = decodeEscapedText(rawUrl).trim();
272
+ if (!normalized) {
273
+ return undefined;
274
+ }
275
+ try {
276
+ const parsed = new URL(normalized);
277
+ if (parsed.hostname !== "accounts.google.com") {
278
+ return undefined;
279
+ }
280
+ return parsed.toString();
281
+ }
282
+ catch {
283
+ return undefined;
284
+ }
285
+ }
286
+ function selectBestVerificationUrl(urls) {
287
+ const unique = Array.from(new Set(urls
288
+ .map((url) => normalizeGoogleVerificationUrl(url))
289
+ .filter(Boolean)));
290
+ if (unique.length === 0) {
291
+ return undefined;
292
+ }
293
+ unique.sort((a, b) => {
294
+ const score = (value) => {
295
+ let total = 0;
296
+ if (value.includes("plt="))
297
+ total += 4;
298
+ if (value.includes("/signin/continue"))
299
+ total += 3;
300
+ if (value.includes("continue="))
301
+ total += 2;
302
+ if (value.includes("service=cloudcode"))
303
+ total += 1;
304
+ return total;
305
+ };
306
+ return score(b) - score(a);
307
+ });
308
+ return unique[0];
309
+ }
310
+ function extractVerificationErrorDetails(bodyText) {
311
+ const decodedBody = decodeEscapedText(bodyText);
312
+ const lowerBody = decodedBody.toLowerCase();
313
+ let validationRequired = lowerBody.includes("validation_required");
314
+ let message;
315
+ const verificationUrls = new Set();
316
+ const collectUrlsFromText = (text) => {
317
+ for (const match of text.matchAll(/https:\/\/accounts\.google\.com\/[^\s"'<>]+/gi)) {
318
+ if (match[0]) {
319
+ verificationUrls.add(match[0]);
320
+ }
321
+ }
322
+ };
323
+ collectUrlsFromText(decodedBody);
324
+ const payloads = [];
325
+ const trimmed = decodedBody.trim();
326
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
327
+ try {
328
+ payloads.push(JSON.parse(trimmed));
329
+ }
330
+ catch { }
331
+ }
332
+ for (const rawLine of decodedBody.split("\n")) {
333
+ const line = rawLine.trim();
334
+ if (!line.startsWith("data:")) {
335
+ continue;
336
+ }
337
+ const payloadText = line.slice(5).trim();
338
+ if (!payloadText || payloadText === "[DONE]") {
339
+ continue;
340
+ }
341
+ try {
342
+ payloads.push(JSON.parse(payloadText));
343
+ }
344
+ catch {
345
+ collectUrlsFromText(payloadText);
346
+ }
347
+ }
348
+ const visited = new Set();
349
+ const walk = (value, key) => {
350
+ if (typeof value === "string") {
351
+ const normalizedValue = decodeEscapedText(value);
352
+ const lowerValue = normalizedValue.toLowerCase();
353
+ const lowerKey = key?.toLowerCase() ?? "";
354
+ if (lowerValue.includes("validation_required")) {
355
+ validationRequired = true;
356
+ }
357
+ if (!message &&
358
+ (lowerKey.includes("message") ||
359
+ lowerKey.includes("detail") ||
360
+ lowerKey.includes("description"))) {
361
+ message = normalizedValue;
362
+ }
363
+ if (lowerKey.includes("validation_url") ||
364
+ lowerKey.includes("verify_url") ||
365
+ lowerKey.includes("verification_url") ||
366
+ lowerKey === "url") {
367
+ verificationUrls.add(normalizedValue);
368
+ }
369
+ collectUrlsFromText(normalizedValue);
370
+ return;
371
+ }
372
+ if (!value || typeof value !== "object" || visited.has(value)) {
373
+ return;
374
+ }
375
+ visited.add(value);
376
+ if (Array.isArray(value)) {
377
+ for (const item of value) {
378
+ walk(item);
379
+ }
380
+ return;
381
+ }
382
+ for (const [childKey, childValue] of Object.entries(value)) {
383
+ walk(childValue, childKey);
384
+ }
385
+ };
386
+ for (const payload of payloads) {
387
+ walk(payload);
388
+ }
389
+ if (!validationRequired) {
390
+ validationRequired =
391
+ lowerBody.includes("verification required") ||
392
+ lowerBody.includes("verify your account") ||
393
+ lowerBody.includes("account verification");
394
+ }
395
+ if (!message) {
396
+ const fallback = decodedBody
397
+ .split("\n")
398
+ .map((line) => line.trim())
399
+ .find((line) => line &&
400
+ !line.startsWith("data:") &&
401
+ /(verify|validation|required)/i.test(line));
402
+ if (fallback) {
403
+ message = fallback;
404
+ }
405
+ }
406
+ return {
407
+ validationRequired,
408
+ message,
409
+ verifyUrl: selectBestVerificationUrl([...verificationUrls]),
410
+ };
411
+ }
412
+ async function verifyAccountAccess(account, client, providerId) {
413
+ const parsed = parseRefreshParts(account.refreshToken);
414
+ if (!parsed.refreshToken) {
415
+ return {
416
+ status: "error",
417
+ message: "Missing refresh token for selected account.",
418
+ };
419
+ }
420
+ const auth = {
421
+ type: "oauth",
422
+ refresh: formatRefreshParts({
423
+ refreshToken: parsed.refreshToken,
424
+ projectId: parsed.projectId ?? account.projectId,
425
+ managedProjectId: parsed.managedProjectId ?? account.managedProjectId,
426
+ }),
427
+ access: "",
428
+ expires: 0,
429
+ };
430
+ let refreshedAuth;
431
+ try {
432
+ refreshedAuth = await refreshAccessToken(auth, client, providerId);
433
+ }
434
+ catch (error) {
435
+ if (error instanceof AntigravityTokenRefreshError) {
436
+ return { status: "error", message: error.message };
437
+ }
438
+ return {
439
+ status: "error",
440
+ message: `Token refresh failed: ${String(error)}`,
441
+ };
442
+ }
443
+ if (!refreshedAuth?.access) {
444
+ return {
445
+ status: "error",
446
+ message: "Could not refresh access token for this account.",
447
+ };
448
+ }
449
+ const projectId = parsed.managedProjectId ??
450
+ parsed.projectId ??
451
+ account.managedProjectId ??
452
+ account.projectId ??
453
+ ANTIGRAVITY_DEFAULT_PROJECT_ID;
454
+ const headers = {
455
+ ...getAntigravityHeaders(),
456
+ Authorization: `Bearer ${refreshedAuth.access}`,
457
+ "Content-Type": "application/json",
458
+ };
459
+ if (projectId) {
460
+ headers["x-goog-user-project"] = projectId;
461
+ }
462
+ const requestBody = {
463
+ model: "gemini-3-flash",
464
+ request: {
465
+ model: "gemini-3-flash",
466
+ contents: [{ role: "user", parts: [{ text: "ping" }] }],
467
+ generationConfig: { maxOutputTokens: 1, temperature: 0 },
468
+ },
469
+ };
470
+ const controller = new AbortController();
471
+ const timeoutId = setTimeout(() => controller.abort(), 20000);
472
+ let response;
473
+ try {
474
+ response = await fetch(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:streamGenerateContent?alt=sse`, {
475
+ method: "POST",
476
+ headers,
477
+ body: JSON.stringify(requestBody),
478
+ signal: controller.signal,
479
+ });
480
+ }
481
+ catch (error) {
482
+ if (error instanceof Error && error.name === "AbortError") {
483
+ return { status: "error", message: "Verification check timed out." };
484
+ }
485
+ return {
486
+ status: "error",
487
+ message: `Verification check failed: ${String(error)}`,
488
+ };
489
+ }
490
+ finally {
491
+ clearTimeout(timeoutId);
492
+ }
493
+ let responseBody = "";
494
+ try {
495
+ responseBody = await response.text();
496
+ }
497
+ catch {
498
+ responseBody = "";
499
+ }
500
+ if (response.ok) {
501
+ return { status: "ok", message: "Account verification check passed." };
502
+ }
503
+ const extracted = extractVerificationErrorDetails(responseBody);
504
+ if (response.status === 403 && extracted.validationRequired) {
505
+ return {
506
+ status: "blocked",
507
+ message: extracted.message ?? "Google requires additional account verification.",
508
+ verifyUrl: extracted.verifyUrl,
509
+ };
510
+ }
511
+ const fallbackMessage = extracted.message ??
512
+ `Request failed (${response.status} ${response.statusText}).`;
513
+ return {
514
+ status: "error",
515
+ message: fallbackMessage,
516
+ };
517
+ }
518
+ async function promptAccountIndexForVerification(accounts) {
519
+ const { createInterface } = await import("node:readline/promises");
520
+ const { stdin, stdout } = await import("node:process");
521
+ const rl = createInterface({ input: stdin, output: stdout });
522
+ try {
523
+ console.log("\nSelect an account to verify:");
524
+ for (const account of accounts) {
525
+ const label = account.email || `Account ${account.index + 1}`;
526
+ console.log(` ${account.index + 1}. ${label}`);
527
+ }
528
+ console.log("");
529
+ while (true) {
530
+ const answer = (await rl.question("Account number (leave blank to cancel): ")).trim();
531
+ if (!answer) {
532
+ return undefined;
533
+ }
534
+ const parsedIndex = Number(answer);
535
+ if (!Number.isInteger(parsedIndex)) {
536
+ console.log("Please enter a valid account number.");
537
+ continue;
538
+ }
539
+ const normalizedIndex = parsedIndex - 1;
540
+ const selected = accounts.find((account) => account.index === normalizedIndex);
541
+ if (!selected) {
542
+ console.log("Please enter a number from the list above.");
543
+ continue;
544
+ }
545
+ return selected.index;
546
+ }
547
+ }
548
+ finally {
549
+ rl.close();
550
+ }
551
+ }
552
+ async function promptOpenVerificationUrl() {
553
+ const answer = (await promptOAuthCallbackValue("Open verification URL in your browser now? [Y/n]: "))
554
+ .trim()
555
+ .toLowerCase();
556
+ return answer === "" || answer === "y" || answer === "yes";
557
+ }
558
+ function markStoredAccountVerificationRequired(account, reason, verifyUrl) {
559
+ let changed = false;
560
+ const wasVerificationRequired = account.verificationRequired === true;
561
+ if (!wasVerificationRequired) {
562
+ account.verificationRequired = true;
563
+ changed = true;
564
+ }
565
+ if (!wasVerificationRequired ||
566
+ account.verificationRequiredAt === undefined) {
567
+ account.verificationRequiredAt = Date.now();
568
+ changed = true;
569
+ }
570
+ const normalizedReason = reason.trim();
571
+ if (account.verificationRequiredReason !== normalizedReason) {
572
+ account.verificationRequiredReason = normalizedReason;
573
+ changed = true;
574
+ }
575
+ const normalizedUrl = verifyUrl?.trim();
576
+ if (normalizedUrl && account.verificationUrl !== normalizedUrl) {
577
+ account.verificationUrl = normalizedUrl;
578
+ changed = true;
579
+ }
580
+ if (account.enabled !== false) {
581
+ account.enabled = false;
582
+ changed = true;
583
+ }
584
+ return changed;
585
+ }
586
+ function clearStoredAccountVerificationRequired(account, enableIfRequired = false) {
587
+ const wasVerificationRequired = account.verificationRequired === true;
588
+ let changed = false;
589
+ if (account.verificationRequired !== false) {
590
+ account.verificationRequired = false;
591
+ changed = true;
592
+ }
593
+ if (account.verificationRequiredAt !== undefined) {
594
+ account.verificationRequiredAt = undefined;
595
+ changed = true;
596
+ }
597
+ if (account.verificationRequiredReason !== undefined) {
598
+ account.verificationRequiredReason = undefined;
599
+ changed = true;
600
+ }
601
+ if (account.verificationUrl !== undefined) {
602
+ account.verificationUrl = undefined;
603
+ changed = true;
604
+ }
605
+ if (enableIfRequired &&
606
+ wasVerificationRequired &&
607
+ account.enabled === false) {
608
+ account.enabled = true;
609
+ changed = true;
610
+ }
611
+ return { changed, wasVerificationRequired };
612
+ }
613
+ async function promptOAuthCallbackValue(message) {
614
+ const { createInterface } = await import("node:readline/promises");
615
+ const { stdin, stdout } = await import("node:process");
616
+ const rl = createInterface({ input: stdin, output: stdout });
617
+ try {
618
+ return (await rl.question(message)).trim();
619
+ }
620
+ finally {
621
+ rl.close();
622
+ }
623
+ }
624
+ function getStateFromAuthorizationUrl(authorizationUrl) {
625
+ try {
626
+ return new URL(authorizationUrl).searchParams.get("state") ?? "";
627
+ }
628
+ catch {
629
+ return "";
630
+ }
631
+ }
632
+ function extractOAuthCallbackParams(url) {
633
+ const code = url.searchParams.get("code");
634
+ const state = url.searchParams.get("state");
635
+ if (!code || !state) {
636
+ return null;
637
+ }
638
+ return { code, state };
639
+ }
640
+ function parseOAuthCallbackInput(value, fallbackState) {
641
+ const trimmed = value.trim();
642
+ if (!trimmed) {
643
+ return { error: "Missing authorization code" };
644
+ }
645
+ try {
646
+ const url = new URL(trimmed);
647
+ const code = url.searchParams.get("code");
648
+ const state = url.searchParams.get("state") ?? fallbackState;
649
+ if (!code) {
650
+ return { error: "Missing code in callback URL" };
651
+ }
652
+ if (!state) {
653
+ return { error: "Missing state in callback URL" };
654
+ }
655
+ return { code, state };
656
+ }
657
+ catch {
658
+ if (!fallbackState) {
659
+ return {
660
+ error: "Missing state. Paste the full redirect URL instead of only the code.",
661
+ };
662
+ }
663
+ return { code: trimmed, state: fallbackState };
664
+ }
665
+ }
666
+ async function promptManualOAuthInput(fallbackState) {
667
+ console.log("1. Open the URL above in your browser and complete Google sign-in.");
668
+ console.log("2. After approving, copy the full redirected localhost URL from the address bar.");
669
+ console.log("3. Paste it back here.\n");
670
+ const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
671
+ const params = parseOAuthCallbackInput(callbackInput, fallbackState);
672
+ if ("error" in params) {
673
+ return { type: "failed", error: params.error };
674
+ }
675
+ return exchangeAntigravity(params.code, params.state);
676
+ }
677
+ function clampInt(value, min, max) {
678
+ if (!Number.isFinite(value)) {
679
+ return min;
680
+ }
681
+ return Math.min(max, Math.max(min, Math.floor(value)));
682
+ }
683
+ async function persistAccountPool(results, replaceAll = false) {
684
+ if (results.length === 0) {
685
+ return;
686
+ }
687
+ const now = Date.now();
688
+ // If replaceAll is true (fresh login), start with empty accounts
689
+ // Otherwise, load existing accounts and merge
690
+ const stored = replaceAll ? null : await loadAccounts();
691
+ const accounts = stored?.accounts ? [...stored.accounts] : [];
692
+ const indexByRefreshToken = new Map();
693
+ const indexByEmail = new Map();
694
+ for (let i = 0; i < accounts.length; i++) {
695
+ const acc = accounts[i];
696
+ if (acc?.refreshToken) {
697
+ indexByRefreshToken.set(acc.refreshToken, i);
698
+ }
699
+ if (acc?.email) {
700
+ indexByEmail.set(acc.email, i);
701
+ }
702
+ }
703
+ for (const result of results) {
704
+ const parts = parseRefreshParts(result.refresh);
705
+ if (!parts.refreshToken) {
706
+ continue;
707
+ }
708
+ // First, check for existing account by email (prevents duplicates when refresh token changes)
709
+ // Only use email-based deduplication if the new account has an email
710
+ const existingByEmail = result.email
711
+ ? indexByEmail.get(result.email)
712
+ : undefined;
713
+ const existingByToken = indexByRefreshToken.get(parts.refreshToken);
714
+ // Prefer email-based match to handle refresh token rotation
715
+ const existingIndex = existingByEmail ?? existingByToken;
716
+ if (existingIndex === undefined) {
717
+ // New account - add it
718
+ const newIndex = accounts.length;
719
+ indexByRefreshToken.set(parts.refreshToken, newIndex);
720
+ if (result.email) {
721
+ indexByEmail.set(result.email, newIndex);
722
+ }
723
+ accounts.push({
724
+ email: result.email,
725
+ refreshToken: parts.refreshToken,
726
+ projectId: parts.projectId,
727
+ managedProjectId: parts.managedProjectId,
728
+ addedAt: now,
729
+ lastUsed: now,
730
+ enabled: true,
731
+ });
732
+ continue;
733
+ }
734
+ const existing = accounts[existingIndex];
735
+ if (!existing) {
736
+ continue;
737
+ }
738
+ // Update existing account (this handles both email match and token match cases)
739
+ // When email matches but token differs, this effectively replaces the old token
740
+ const oldToken = existing.refreshToken;
741
+ accounts[existingIndex] = {
742
+ ...existing,
743
+ email: result.email ?? existing.email,
744
+ refreshToken: parts.refreshToken,
745
+ projectId: parts.projectId ?? existing.projectId,
746
+ managedProjectId: parts.managedProjectId ?? existing.managedProjectId,
747
+ lastUsed: now,
748
+ };
749
+ // Update the token index if the token changed
750
+ if (oldToken !== parts.refreshToken) {
751
+ indexByRefreshToken.delete(oldToken);
752
+ indexByRefreshToken.set(parts.refreshToken, existingIndex);
753
+ }
754
+ }
755
+ if (accounts.length === 0) {
756
+ return;
757
+ }
758
+ // For fresh logins, always start at index 0
759
+ const activeIndex = replaceAll
760
+ ? 0
761
+ : typeof stored?.activeIndex === "number" &&
762
+ Number.isFinite(stored.activeIndex)
763
+ ? stored.activeIndex
764
+ : 0;
765
+ await saveAccounts({
766
+ version: 4,
767
+ accounts,
768
+ activeIndex: clampInt(activeIndex, 0, accounts.length - 1),
769
+ activeIndexByFamily: {
770
+ claude: clampInt(activeIndex, 0, accounts.length - 1),
771
+ gemini: clampInt(activeIndex, 0, accounts.length - 1),
772
+ },
773
+ });
774
+ }
775
+ function buildAuthSuccessFromStoredAccount(account) {
776
+ const refresh = formatRefreshParts({
777
+ refreshToken: account.refreshToken,
778
+ projectId: account.projectId,
779
+ managedProjectId: account.managedProjectId,
780
+ });
781
+ return {
782
+ type: "success",
783
+ refresh,
784
+ access: "",
785
+ expires: 0,
786
+ email: account.email,
787
+ projectId: account.projectId ?? "",
788
+ };
789
+ }
790
+ function retryAfterMsFromResponse(response, defaultRetryMs = 60_000) {
791
+ const retryAfterMsHeader = response.headers.get("retry-after-ms");
792
+ if (retryAfterMsHeader) {
793
+ const parsed = Number.parseInt(retryAfterMsHeader, 10);
794
+ if (!Number.isNaN(parsed) && parsed > 0) {
795
+ return parsed;
796
+ }
797
+ }
798
+ const retryAfterHeader = response.headers.get("retry-after");
799
+ if (retryAfterHeader) {
800
+ const parsed = Number.parseInt(retryAfterHeader, 10);
801
+ if (!Number.isNaN(parsed) && parsed > 0) {
802
+ return parsed * 1000;
803
+ }
804
+ }
805
+ return defaultRetryMs;
806
+ }
807
+ /**
808
+ * Parse Go-style duration strings to milliseconds.
809
+ * Supports compound durations: "1h16m0.667s", "1.5s", "200ms", "5m30s"
810
+ *
811
+ * @param duration - Duration string in Go format
812
+ * @returns Duration in milliseconds, or null if parsing fails
813
+ */
814
+ function parseDurationToMs(duration) {
815
+ // Handle simple formats first for backwards compatibility
816
+ const simpleMatch = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i);
817
+ if (simpleMatch) {
818
+ const value = parseFloat(simpleMatch[1]);
819
+ const unit = (simpleMatch[2] || "s").toLowerCase();
820
+ switch (unit) {
821
+ case "h":
822
+ return value * 3600 * 1000;
823
+ case "m":
824
+ return value * 60 * 1000;
825
+ case "s":
826
+ return value * 1000;
827
+ case "ms":
828
+ return value;
829
+ default:
830
+ return value * 1000;
831
+ }
832
+ }
833
+ // Parse compound Go-style durations: "1h16m0.667s", "5m30s", etc.
834
+ const compoundRegex = /(\d+(?:\.\d+)?)(h|m(?!s)|s|ms)/gi;
835
+ let totalMs = 0;
836
+ let matchFound = false;
837
+ let match;
838
+ while ((match = compoundRegex.exec(duration)) !== null) {
839
+ matchFound = true;
840
+ const value = parseFloat(match[1]);
841
+ const unit = match[2].toLowerCase();
842
+ switch (unit) {
843
+ case "h":
844
+ totalMs += value * 3600 * 1000;
845
+ break;
846
+ case "m":
847
+ totalMs += value * 60 * 1000;
848
+ break;
849
+ case "s":
850
+ totalMs += value * 1000;
851
+ break;
852
+ case "ms":
853
+ totalMs += value;
854
+ break;
855
+ }
856
+ }
857
+ return matchFound ? totalMs : null;
858
+ }
859
+ function extractRateLimitBodyInfo(body) {
860
+ if (!body || typeof body !== "object") {
861
+ return { retryDelayMs: null };
862
+ }
863
+ const error = body.error;
864
+ const message = error && typeof error === "object"
865
+ ? error.message
866
+ : undefined;
867
+ const details = error && typeof error === "object"
868
+ ? error.details
869
+ : undefined;
870
+ let reason;
871
+ if (Array.isArray(details)) {
872
+ for (const detail of details) {
873
+ if (!detail || typeof detail !== "object")
874
+ continue;
875
+ const type = detail["@type"];
876
+ if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) {
877
+ const detailReason = detail.reason;
878
+ if (typeof detailReason === "string") {
879
+ reason = detailReason;
880
+ break;
881
+ }
882
+ }
883
+ }
884
+ for (const detail of details) {
885
+ if (!detail || typeof detail !== "object")
886
+ continue;
887
+ const type = detail["@type"];
888
+ if (typeof type === "string" && type.includes("google.rpc.RetryInfo")) {
889
+ const retryDelay = detail.retryDelay;
890
+ if (typeof retryDelay === "string") {
891
+ const retryDelayMs = parseDurationToMs(retryDelay);
892
+ if (retryDelayMs !== null) {
893
+ return { retryDelayMs, message, reason };
894
+ }
895
+ }
896
+ }
897
+ }
898
+ for (const detail of details) {
899
+ if (!detail || typeof detail !== "object")
900
+ continue;
901
+ const metadata = detail
902
+ .metadata;
903
+ if (metadata && typeof metadata === "object") {
904
+ const quotaResetDelay = metadata.quotaResetDelay;
905
+ const quotaResetTime = metadata.quotaResetTimeStamp;
906
+ if (typeof quotaResetDelay === "string") {
907
+ const quotaResetDelayMs = parseDurationToMs(quotaResetDelay);
908
+ if (quotaResetDelayMs !== null) {
909
+ return {
910
+ retryDelayMs: quotaResetDelayMs,
911
+ message,
912
+ quotaResetTime,
913
+ reason,
914
+ };
915
+ }
916
+ }
917
+ }
918
+ }
919
+ }
920
+ if (message) {
921
+ const afterMatch = message.match(/reset after\s+([0-9hms.]+)/i);
922
+ const rawDuration = afterMatch?.[1];
923
+ if (rawDuration) {
924
+ const parsed = parseDurationToMs(rawDuration);
925
+ if (parsed !== null) {
926
+ return { retryDelayMs: parsed, message, reason };
927
+ }
928
+ }
929
+ }
930
+ return { retryDelayMs: null, message, reason };
931
+ }
932
+ async function extractRetryInfoFromBody(response) {
933
+ try {
934
+ const text = await response.clone().text();
935
+ try {
936
+ const parsed = JSON.parse(text);
937
+ return extractRateLimitBodyInfo(parsed);
938
+ }
939
+ catch {
940
+ return { retryDelayMs: null };
941
+ }
942
+ }
943
+ catch {
944
+ return { retryDelayMs: null };
945
+ }
946
+ }
947
+ function formatWaitTime(ms) {
948
+ if (ms < 1000)
949
+ return `${ms}ms`;
950
+ const seconds = Math.ceil(ms / 1000);
951
+ if (seconds < 60)
952
+ return `${seconds}s`;
953
+ const minutes = Math.floor(seconds / 60);
954
+ const remainingSeconds = seconds % 60;
955
+ if (minutes < 60) {
956
+ return remainingSeconds > 0
957
+ ? `${minutes}m ${remainingSeconds}s`
958
+ : `${minutes}m`;
959
+ }
960
+ const hours = Math.floor(minutes / 60);
961
+ const remainingMinutes = minutes % 60;
962
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
963
+ }
964
+ // Progressive rate limit retry delays
965
+ const FIRST_RETRY_DELAY_MS = 1000; // 1s - first 429 quick retry on same account
966
+ const SWITCH_ACCOUNT_DELAY_MS = 5000; // 5s - delay before switching to another account
967
+ /**
968
+ * Rate limit state tracking with time-window deduplication.
969
+ *
970
+ * Problem: When multiple subagents hit 429 simultaneously, each would increment
971
+ * the consecutive counter, causing incorrect exponential backoff (5 concurrent
972
+ * 429s = 2^5 backoff instead of 2^1).
973
+ *
974
+ * Solution: Track per account+quota with deduplication window. Multiple 429s
975
+ * within RATE_LIMIT_DEDUP_WINDOW_MS are treated as a single event.
976
+ */
977
+ const RATE_LIMIT_DEDUP_WINDOW_MS = 2000; // 2 seconds - concurrent requests within this window are deduplicated
978
+ const RATE_LIMIT_STATE_RESET_MS = 120_000; // Reset consecutive counter after 2 minutes of no 429s
979
+ // Key format: `${accountIndex}:${quotaKey}` for per-account-per-quota tracking
980
+ const rateLimitStateByAccountQuota = new Map();
981
+ // Track empty response retry attempts (ported from LLM-API-Key-Proxy)
982
+ const emptyResponseAttempts = new Map();
983
+ /**
984
+ * Get rate limit backoff with time-window deduplication.
985
+ *
986
+ * @param accountIndex - The account index
987
+ * @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude")
988
+ * @param serverRetryAfterMs - Server-provided retry delay (if any)
989
+ * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000)
990
+ * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window
991
+ */
992
+ function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs, maxBackoffMs = 60_000) {
993
+ const now = Date.now();
994
+ const stateKey = `${accountIndex}:${quotaKey}`;
995
+ const previous = rateLimitStateByAccountQuota.get(stateKey);
996
+ // Check if this is a duplicate 429 within the dedup window
997
+ if (previous && now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS) {
998
+ // Same rate limit event from concurrent request - don't increment
999
+ const baseDelay = serverRetryAfterMs ?? 1000;
1000
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs);
1001
+ return {
1002
+ attempt: previous.consecutive429,
1003
+ delayMs: Math.max(baseDelay, backoffDelay),
1004
+ isDuplicate: true,
1005
+ };
1006
+ }
1007
+ // Check if we should reset (no 429 for 2 minutes) or increment
1008
+ const attempt = previous && now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS
1009
+ ? previous.consecutive429 + 1
1010
+ : 1;
1011
+ rateLimitStateByAccountQuota.set(stateKey, {
1012
+ consecutive429: attempt,
1013
+ lastAt: now,
1014
+ quotaKey,
1015
+ });
1016
+ const baseDelay = serverRetryAfterMs ?? 1000;
1017
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs);
1018
+ return {
1019
+ attempt,
1020
+ delayMs: Math.max(baseDelay, backoffDelay),
1021
+ isDuplicate: false,
1022
+ };
1023
+ }
1024
+ /**
1025
+ * Reset rate limit state for an account+quota combination.
1026
+ * Only resets the specific quota, not all quotas for the account.
1027
+ */
1028
+ function resetRateLimitState(accountIndex, quotaKey) {
1029
+ const stateKey = `${accountIndex}:${quotaKey}`;
1030
+ rateLimitStateByAccountQuota.delete(stateKey);
1031
+ }
1032
+ /**
1033
+ * Reset all rate limit state for an account (all quotas).
1034
+ * Used when account is completely healthy.
1035
+ */
1036
+ function resetAllRateLimitStateForAccount(accountIndex) {
1037
+ for (const key of rateLimitStateByAccountQuota.keys()) {
1038
+ if (key.startsWith(`${accountIndex}:`)) {
1039
+ rateLimitStateByAccountQuota.delete(key);
1040
+ }
1041
+ }
1042
+ }
1043
+ function headerStyleToQuotaKey(headerStyle, family) {
1044
+ if (family === "claude")
1045
+ return "claude";
1046
+ return headerStyle === "antigravity" ? "gemini-antigravity" : "gemini-cli";
1047
+ }
1048
+ // Track consecutive non-429 failures per account to prevent infinite loops
1049
+ const accountFailureState = new Map();
1050
+ const MAX_CONSECUTIVE_FAILURES = 5;
1051
+ const FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures
1052
+ const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures
1053
+ function trackAccountFailure(accountIndex) {
1054
+ const now = Date.now();
1055
+ const previous = accountFailureState.get(accountIndex);
1056
+ // Reset if last failure was more than 2 minutes ago
1057
+ const failures = previous && now - previous.lastFailureAt < FAILURE_STATE_RESET_MS
1058
+ ? previous.consecutiveFailures + 1
1059
+ : 1;
1060
+ accountFailureState.set(accountIndex, {
1061
+ consecutiveFailures: failures,
1062
+ lastFailureAt: now,
1063
+ });
1064
+ const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES;
1065
+ const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0;
1066
+ return { failures, shouldCooldown, cooldownMs };
1067
+ }
1068
+ function resetAccountFailureState(accountIndex) {
1069
+ accountFailureState.delete(accountIndex);
1070
+ }
1071
+ /**
1072
+ * Sleep for a given number of milliseconds, respecting an abort signal.
1073
+ */
1074
+ function sleep(ms, signal) {
1075
+ return new Promise((resolve, reject) => {
1076
+ if (signal?.aborted) {
1077
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
1078
+ return;
1079
+ }
1080
+ const timeout = setTimeout(() => {
1081
+ cleanup();
1082
+ resolve();
1083
+ }, ms);
1084
+ const onAbort = () => {
1085
+ cleanup();
1086
+ reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
1087
+ };
1088
+ const cleanup = () => {
1089
+ clearTimeout(timeout);
1090
+ signal?.removeEventListener("abort", onAbort);
1091
+ };
1092
+ signal?.addEventListener("abort", onAbort, { once: true });
1093
+ });
1094
+ }
1095
+ /**
1096
+ * Creates an Antigravity OAuth plugin for a specific provider ID.
1097
+ */
1098
+ export const createAntigravityPlugin = (providerId) => async ({ client, directory }) => {
1099
+ // Load configuration from files and environment variables
1100
+ const config = loadConfig(directory);
1101
+ initRuntimeConfig(config);
1102
+ cachedModelLimits = await readModelLimits(directory);
1103
+ // Cached getAuth function for tool access
1104
+ let cachedGetAuth = null;
1105
+ // Initialize debug with config
1106
+ initializeDebug(config);
1107
+ // Initialize structured logger for TUI integration
1108
+ initLogger(client);
1109
+ // Fetch latest Antigravity version from remote API (non-blocking, falls back to hardcoded)
1110
+ await initAntigravityVersion(ANTIGRAVITY_VERSION_FALLBACK);
1111
+ // Initialize health tracker for hybrid strategy
1112
+ if (config.health_score) {
1113
+ initHealthTracker({
1114
+ initial: config.health_score.initial,
1115
+ successReward: config.health_score.success_reward,
1116
+ rateLimitPenalty: config.health_score.rate_limit_penalty,
1117
+ failurePenalty: config.health_score.failure_penalty,
1118
+ recoveryRatePerHour: config.health_score.recovery_rate_per_hour,
1119
+ minUsable: config.health_score.min_usable,
1120
+ maxScore: config.health_score.max_score,
1121
+ });
1122
+ }
1123
+ // Initialize token tracker for hybrid strategy
1124
+ if (config.token_bucket) {
1125
+ initTokenTracker({
1126
+ maxTokens: config.token_bucket.max_tokens,
1127
+ regenerationRatePerMinute: config.token_bucket.regeneration_rate_per_minute,
1128
+ initialTokens: config.token_bucket.initial_tokens,
1129
+ });
1130
+ }
1131
+ // Initialize disk signature cache if keep_thinking is enabled
1132
+ // This integrates with the in-memory cacheSignature/getCachedSignature functions
1133
+ if (config.keep_thinking) {
1134
+ initDiskSignatureCache(config.signature_cache);
1135
+ }
1136
+ // Initialize session recovery hook with full context
1137
+ const sessionRecovery = createSessionRecoveryHook({ client, directory }, config);
1138
+ const updateChecker = createAutoUpdateCheckerHook(client, directory, {
1139
+ showStartupToast: true,
1140
+ autoUpdate: config.auto_update,
1141
+ });
1142
+ // Event handler for session recovery and updates
1143
+ const eventHandler = async (input) => {
1144
+ // Forward to update checker
1145
+ await updateChecker.event(input);
1146
+ // Track if this is a child session (subagent, background task)
1147
+ // This is used to filter toasts based on toast_scope config
1148
+ if (input.event.type === "session.created") {
1149
+ const props = input.event.properties;
1150
+ // Capture the real OpenCode session ID for programmatic commands
1151
+ if (props?.info?.id) {
1152
+ currentOpenCodeSessionId = props.info.id;
1153
+ }
1154
+ else {
1155
+ // Reset for new sessions that don't provide an ID (unlikely but safe)
1156
+ currentOpenCodeSessionId = undefined;
1157
+ }
1158
+ if (props?.info?.parentID) {
1159
+ isChildSession = true;
1160
+ childSessionParentID = props.info.parentID;
1161
+ log.debug("child-session-detected", {
1162
+ parentID: props.info.parentID,
1163
+ });
1164
+ }
1165
+ else {
1166
+ // Reset for root sessions - important when plugin instance is reused
1167
+ isChildSession = false;
1168
+ childSessionParentID = undefined;
1169
+ log.debug("root-session-detected", {});
1170
+ }
1171
+ }
1172
+ // Handle session recovery
1173
+ if (sessionRecovery && input.event.type === "session.error") {
1174
+ const props = input.event.properties;
1175
+ const sessionID = props?.sessionID;
1176
+ const messageID = props?.messageID;
1177
+ const error = props?.error;
1178
+ if (sessionRecovery.isRecoverableError(error)) {
1179
+ const messageInfo = {
1180
+ id: messageID,
1181
+ role: "assistant",
1182
+ sessionID,
1183
+ error,
1184
+ };
1185
+ // handleSessionRecovery now does the actual fix (injects tool_result, etc.)
1186
+ const recovered = await sessionRecovery.handleSessionRecovery(messageInfo);
1187
+ // Only send "continue" AFTER successful tool_result_missing recovery
1188
+ // (thinking recoveries already resume inside handleSessionRecovery)
1189
+ if (recovered && sessionID && config.auto_resume) {
1190
+ // For tool_result_missing, we need to send continue after injecting tool_results
1191
+ await client.session
1192
+ .prompt({
1193
+ path: { id: sessionID },
1194
+ body: { parts: [{ type: "text", text: config.resume_text }] },
1195
+ query: { directory },
1196
+ })
1197
+ .catch(() => { });
1198
+ // Show success toast (respects toast_scope for child sessions)
1199
+ const successToast = getRecoverySuccessToast();
1200
+ log.debug("recovery-toast", {
1201
+ ...successToast,
1202
+ isChildSession,
1203
+ toastScope: config.toast_scope,
1204
+ });
1205
+ if (!(config.toast_scope === "root_only" && isChildSession)) {
1206
+ await client.tui
1207
+ .showToast({
1208
+ body: {
1209
+ title: successToast.title,
1210
+ message: successToast.message,
1211
+ variant: "success",
1212
+ },
1213
+ })
1214
+ .catch(() => { });
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ };
1220
+ // Create google_search tool with access to auth context
1221
+ const googleSearchTool = tool({
1222
+ 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.",
1223
+ args: {
1224
+ query: tool.schema
1225
+ .string()
1226
+ .describe("The search query or question to answer using web search"),
1227
+ urls: tool.schema
1228
+ .array(tool.schema.string())
1229
+ .optional()
1230
+ .describe("List of specific URLs to fetch and analyze. IMPORTANT: Always extract and include any URLs mentioned by the user in their query here."),
1231
+ thinking: tool.schema
1232
+ .boolean()
1233
+ .optional()
1234
+ .default(true)
1235
+ .describe("Enable deep thinking for more thorough analysis (default: true)"),
1236
+ },
1237
+ async execute(args, ctx) {
1238
+ log.debug("Google Search tool called", {
1239
+ query: args.query,
1240
+ urlCount: args.urls?.length ?? 0,
1241
+ });
1242
+ // Get current auth context
1243
+ const auth = cachedGetAuth ? await cachedGetAuth() : null;
1244
+ if (!auth || !isOAuthAuth(auth)) {
1245
+ return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate.";
1246
+ }
1247
+ // Get access token and project ID
1248
+ const parts = parseRefreshParts(auth.refresh);
1249
+ const projectId = parts.managedProjectId || parts.projectId || "unknown";
1250
+ // Ensure we have a valid access token
1251
+ let accessToken = auth.access;
1252
+ if (!accessToken || accessTokenExpired(auth)) {
1253
+ try {
1254
+ const refreshed = await refreshAccessToken(auth, client, providerId);
1255
+ accessToken = refreshed?.access;
1256
+ }
1257
+ catch (error) {
1258
+ return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`;
1259
+ }
1260
+ }
1261
+ if (!accessToken) {
1262
+ return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate.";
1263
+ }
1264
+ return executeSearch({
1265
+ query: args.query,
1266
+ urls: args.urls,
1267
+ thinking: args.thinking,
1268
+ }, accessToken, projectId, ctx.abort);
1269
+ },
1270
+ });
1271
+ return {
1272
+ event: eventHandler,
1273
+ tool: {
1274
+ google_search: googleSearchTool,
1275
+ },
1276
+ auth: {
1277
+ provider: providerId,
1278
+ loader: async (getAuth, provider) => {
1279
+ // Cache getAuth for tool access
1280
+ cachedGetAuth = getAuth;
1281
+ const auth = await getAuth();
1282
+ // If OpenCode has no valid OAuth auth, clear any stale account storage
1283
+ if (!isOAuthAuth(auth)) {
1284
+ try {
1285
+ await clearAccounts();
1286
+ }
1287
+ catch {
1288
+ // ignore
1289
+ }
1290
+ return {};
1291
+ }
1292
+ // Validate that stored accounts are in sync with OpenCode's auth
1293
+ // If OpenCode's refresh token doesn't match any stored account, clear stale storage
1294
+ const authParts = parseRefreshParts(auth.refresh);
1295
+ const storedAccounts = await loadAccounts();
1296
+ // Note: AccountManager now ensures the current auth is always included in accounts
1297
+ const accountManager = await AccountManager.loadFromDisk(auth);
1298
+ activeAccountManager = accountManager;
1299
+ if (accountManager.getAccountCount() > 0) {
1300
+ accountManager.requestSaveToDisk();
1301
+ }
1302
+ // Initialize proactive token refresh queue (ported from LLM-API-Key-Proxy)
1303
+ let refreshQueue = null;
1304
+ if (config.proactive_token_refresh &&
1305
+ accountManager.getAccountCount() > 0) {
1306
+ refreshQueue = createProactiveRefreshQueue(client, providerId, {
1307
+ enabled: config.proactive_token_refresh,
1308
+ bufferSeconds: config.proactive_refresh_buffer_seconds,
1309
+ checkIntervalSeconds: config.proactive_refresh_check_interval_seconds,
1310
+ });
1311
+ refreshQueue.setAccountManager(accountManager);
1312
+ refreshQueue.start();
1313
+ }
1314
+ if (isDebugEnabled()) {
1315
+ const logPath = getLogFilePath();
1316
+ if (logPath) {
1317
+ try {
1318
+ await client.tui.showToast({
1319
+ body: { message: `Debug log: ${logPath}`, variant: "info" },
1320
+ });
1321
+ }
1322
+ catch {
1323
+ // TUI may not be available
1324
+ }
1325
+ }
1326
+ }
1327
+ if (provider.models) {
1328
+ for (const model of Object.values(provider.models)) {
1329
+ if (model) {
1330
+ model.cost = { input: 0, output: 0 };
1331
+ }
1332
+ }
1333
+ }
1334
+ return {
1335
+ apiKey: "",
1336
+ async fetch(input, init) {
1337
+ if (!isGenerativeLanguageRequest(input)) {
1338
+ return fetch(input, init);
1339
+ }
1340
+ const latestAuth = await getAuth();
1341
+ if (!isOAuthAuth(latestAuth)) {
1342
+ return fetch(input, init);
1343
+ }
1344
+ if (accountManager.getAccountCount() === 0) {
1345
+ throw new Error("No Antigravity accounts configured. Run `opencode auth login`.");
1346
+ }
1347
+ const urlString = toUrlString(input);
1348
+ const family = getModelFamilyFromUrl(urlString);
1349
+ const model = extractModelFromUrl(urlString);
1350
+ const debugLines = [];
1351
+ const pushDebug = (line) => {
1352
+ if (!isDebugEnabled())
1353
+ return;
1354
+ debugLines.push(line);
1355
+ };
1356
+ pushDebug(`request=${urlString}`);
1357
+ let lastFailure = null;
1358
+ let lastError = null;
1359
+ const abortSignal = init?.signal ?? undefined;
1360
+ // Helper to check if request was aborted
1361
+ const checkAborted = () => {
1362
+ if (abortSignal?.aborted) {
1363
+ throw abortSignal.reason instanceof Error
1364
+ ? abortSignal.reason
1365
+ : new Error("Aborted");
1366
+ }
1367
+ };
1368
+ // Use while(true) loop to handle rate limits with backoff
1369
+ // This ensures we wait and retry when all accounts are rate-limited
1370
+ const quietMode = config.quiet_mode;
1371
+ const toastScope = config.toast_scope;
1372
+ // Helper to show toast without blocking on abort (respects quiet_mode and toast_scope)
1373
+ const showToast = async (message, variant) => {
1374
+ // Always log to debug regardless of toast filtering
1375
+ log.debug("toast", {
1376
+ message,
1377
+ variant,
1378
+ isChildSession,
1379
+ toastScope,
1380
+ });
1381
+ if (quietMode)
1382
+ return;
1383
+ if (abortSignal?.aborted)
1384
+ return;
1385
+ // Filter toasts for child sessions when toast_scope is "root_only"
1386
+ if (toastScope === "root_only" && isChildSession) {
1387
+ log.debug("toast-suppressed-child-session", {
1388
+ message,
1389
+ variant,
1390
+ parentID: childSessionParentID,
1391
+ });
1392
+ return;
1393
+ }
1394
+ if (variant === "warning" &&
1395
+ message.toLowerCase().includes("rate")) {
1396
+ if (!shouldShowRateLimitToast(message)) {
1397
+ return;
1398
+ }
1399
+ }
1400
+ try {
1401
+ await client.tui.showToast({
1402
+ body: { message, variant },
1403
+ });
1404
+ }
1405
+ catch {
1406
+ // TUI may not be available
1407
+ }
1408
+ };
1409
+ const hasOtherAccountWithAntigravity = (currentAccount) => {
1410
+ if (family !== "gemini")
1411
+ return false;
1412
+ // Use AccountManager method which properly checks for disabled/cooling-down accounts
1413
+ return accountManager.hasOtherAccountWithAntigravityAvailable(currentAccount.index, family, model);
1414
+ };
1415
+ while (true) {
1416
+ // Check for abort at the start of each iteration
1417
+ checkAborted();
1418
+ const accountCount = accountManager.getAccountCount();
1419
+ const routingDecision = resolveHeaderRoutingDecision(urlString, family, config);
1420
+ const { cliFirst, preferredHeaderStyle, explicitQuota, allowQuotaFallback, } = routingDecision;
1421
+ if (accountCount === 0) {
1422
+ throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
1423
+ }
1424
+ const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs(config.soft_quota_cache_ttl_minutes, config.quota_refresh_interval_minutes);
1425
+ let account = accountManager.getCurrentOrNextForFamily(family, model, config.account_selection_strategy, preferredHeaderStyle, config.pid_offset_enabled, config.soft_quota_threshold_percent, softQuotaCacheTtlMs);
1426
+ if (!account && allowQuotaFallback) {
1427
+ const alternateHeaderStyle = preferredHeaderStyle === "antigravity"
1428
+ ? "gemini-cli"
1429
+ : "antigravity";
1430
+ account = accountManager.getCurrentOrNextForFamily(family, model, config.account_selection_strategy, alternateHeaderStyle, config.pid_offset_enabled, config.soft_quota_threshold_percent, softQuotaCacheTtlMs);
1431
+ if (account) {
1432
+ pushDebug(`selected-by-fallback idx=${account.index} preferred=${preferredHeaderStyle} alternate=${alternateHeaderStyle}`);
1433
+ }
1434
+ }
1435
+ if (!account) {
1436
+ if (accountManager.areAllAccountsOverSoftQuota(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) {
1437
+ const threshold = config.soft_quota_threshold_percent;
1438
+ const softQuotaWaitMs = accountManager.getMinWaitTimeForSoftQuota(family, threshold, softQuotaCacheTtlMs, model);
1439
+ const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000;
1440
+ if (softQuotaWaitMs === null ||
1441
+ (maxWaitMs > 0 && softQuotaWaitMs > maxWaitMs)) {
1442
+ const waitTimeFormatted = softQuotaWaitMs
1443
+ ? formatWaitTime(softQuotaWaitMs)
1444
+ : "unknown";
1445
+ await showToast(`All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`, "error");
1446
+ throw new Error(`Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` +
1447
+ `Quota resets in ${waitTimeFormatted}. ` +
1448
+ `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.`);
1449
+ }
1450
+ const waitSecValue = Math.max(1, Math.ceil(softQuotaWaitMs / 1000));
1451
+ pushDebug(`all-over-soft-quota family=${family} accounts=${accountCount} waitMs=${softQuotaWaitMs}`);
1452
+ if (!softQuotaToastShown) {
1453
+ await showToast(`All ${accountCount} account(s) over ${threshold}% quota. Waiting ${formatWaitTime(softQuotaWaitMs)}...`, "warning");
1454
+ softQuotaToastShown = true;
1455
+ }
1456
+ await sleep(softQuotaWaitMs, abortSignal);
1457
+ continue;
1458
+ }
1459
+ const strictWait = !allowQuotaFallback;
1460
+ // All accounts are rate-limited - wait and retry
1461
+ const waitMs = accountManager.getMinWaitTimeForFamily(family, model, preferredHeaderStyle, strictWait) || 60_000;
1462
+ const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000));
1463
+ pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`);
1464
+ if (isDebugEnabled()) {
1465
+ logAccountContext("All accounts rate-limited", {
1466
+ index: -1,
1467
+ family,
1468
+ totalAccounts: accountCount,
1469
+ });
1470
+ logRateLimitSnapshot(family, accountManager.getAccountsSnapshot());
1471
+ }
1472
+ // If wait time exceeds max threshold, return error immediately instead of hanging
1473
+ // 0 means disabled (wait indefinitely)
1474
+ const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000;
1475
+ if (maxWaitMs > 0 && waitMs > maxWaitMs) {
1476
+ const waitTimeFormatted = formatWaitTime(waitMs);
1477
+ await showToast(`Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, "error");
1478
+ // Return a proper rate limit error response
1479
+ throw new Error(`All ${accountCount} account(s) rate-limited for ${family}. ` +
1480
+ `Quota resets in ${waitTimeFormatted}. ` +
1481
+ `Add more accounts with \`opencode auth login\` or wait and retry.`);
1482
+ }
1483
+ if (!rateLimitToastShown) {
1484
+ await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning");
1485
+ rateLimitToastShown = true;
1486
+ }
1487
+ // Wait for the rate-limit cooldown to expire, then retry
1488
+ await sleep(waitMs, abortSignal);
1489
+ continue;
1490
+ }
1491
+ // Account is available - reset the toast flag
1492
+ resetAllAccountsBlockedToasts();
1493
+ pushDebug(`selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`);
1494
+ if (isDebugEnabled()) {
1495
+ logAccountContext("Selected", {
1496
+ index: account.index,
1497
+ email: account.email,
1498
+ family,
1499
+ totalAccounts: accountCount,
1500
+ rateLimitState: account.rateLimitResetTimes,
1501
+ });
1502
+ }
1503
+ // Show toast when switching to a different account (debounced, quiet_mode handled by showToast)
1504
+ if (accountCount > 1 &&
1505
+ accountManager.shouldShowAccountToast(account.index)) {
1506
+ const accountLabel = account.email || `Account ${account.index + 1}`;
1507
+ // Calculate position among enabled accounts (not absolute index)
1508
+ const enabledAccounts = accountManager.getEnabledAccounts();
1509
+ const enabledPosition = enabledAccounts.findIndex((a) => a.index === account.index) + 1;
1510
+ await showToast(`Using ${accountLabel} (${enabledPosition}/${accountCount})`, "info");
1511
+ accountManager.markToastShown(account.index);
1512
+ }
1513
+ accountManager.requestSaveToDisk();
1514
+ let authRecord = accountManager.toAuthDetails(account);
1515
+ if (accessTokenExpired(authRecord)) {
1516
+ try {
1517
+ const refreshed = await refreshAccessToken(authRecord, client, providerId);
1518
+ if (!refreshed) {
1519
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
1520
+ getHealthTracker().recordFailure(account.index);
1521
+ lastError = new Error("Antigravity token refresh failed");
1522
+ if (shouldCooldown) {
1523
+ accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure");
1524
+ accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model);
1525
+ pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`);
1526
+ }
1527
+ continue;
1528
+ }
1529
+ resetAccountFailureState(account.index);
1530
+ accountManager.updateFromAuth(account, refreshed);
1531
+ authRecord = refreshed;
1532
+ try {
1533
+ await accountManager.saveToDisk();
1534
+ }
1535
+ catch (error) {
1536
+ log.error("Failed to persist refreshed auth", {
1537
+ error: String(error),
1538
+ });
1539
+ }
1540
+ }
1541
+ catch (error) {
1542
+ if (error instanceof AntigravityTokenRefreshError &&
1543
+ error.code === "invalid_grant") {
1544
+ const removed = accountManager.removeAccount(account);
1545
+ if (removed) {
1546
+ log.warn("Removed revoked account from pool - reauthenticate via `opencode auth login`");
1547
+ try {
1548
+ await accountManager.saveToDisk();
1549
+ }
1550
+ catch (persistError) {
1551
+ log.error("Failed to persist revoked account removal", { error: String(persistError) });
1552
+ }
1553
+ }
1554
+ if (accountManager.getAccountCount() === 0) {
1555
+ try {
1556
+ await client.auth.set({
1557
+ path: { id: providerId },
1558
+ body: {
1559
+ type: "oauth",
1560
+ refresh: "",
1561
+ access: "",
1562
+ expires: 0,
1563
+ },
1564
+ });
1565
+ }
1566
+ catch (storeError) {
1567
+ log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) });
1568
+ }
1569
+ throw new Error("All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.");
1570
+ }
1571
+ lastError = error;
1572
+ continue;
1573
+ }
1574
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
1575
+ getHealthTracker().recordFailure(account.index);
1576
+ lastError =
1577
+ error instanceof Error ? error : new Error(String(error));
1578
+ if (shouldCooldown) {
1579
+ accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure");
1580
+ accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model);
1581
+ pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`);
1582
+ }
1583
+ continue;
1584
+ }
1585
+ }
1586
+ const accessToken = authRecord.access;
1587
+ if (!accessToken) {
1588
+ lastError = new Error("Missing access token");
1589
+ if (accountCount <= 1) {
1590
+ throw lastError;
1591
+ }
1592
+ continue;
1593
+ }
1594
+ let projectContext;
1595
+ try {
1596
+ projectContext = await ensureProjectContext(authRecord);
1597
+ resetAccountFailureState(account.index);
1598
+ }
1599
+ catch (error) {
1600
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
1601
+ getHealthTracker().recordFailure(account.index);
1602
+ lastError =
1603
+ error instanceof Error ? error : new Error(String(error));
1604
+ if (shouldCooldown) {
1605
+ accountManager.markAccountCoolingDown(account, cooldownMs, "project-error");
1606
+ accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model);
1607
+ pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`);
1608
+ }
1609
+ continue;
1610
+ }
1611
+ if (projectContext.auth.refresh !== authRecord.refresh ||
1612
+ projectContext.auth.access !== authRecord.access) {
1613
+ accountManager.updateFromAuth(account, projectContext.auth);
1614
+ authRecord = projectContext.auth;
1615
+ try {
1616
+ await accountManager.saveToDisk();
1617
+ }
1618
+ catch (error) {
1619
+ log.error("Failed to persist project context", {
1620
+ error: String(error),
1621
+ });
1622
+ }
1623
+ }
1624
+ const runThinkingWarmup = async (prepared, projectId) => {
1625
+ if (!prepared.needsSignedThinkingWarmup ||
1626
+ !prepared.sessionId) {
1627
+ return;
1628
+ }
1629
+ if (!trackWarmupAttempt(prepared.sessionId)) {
1630
+ return;
1631
+ }
1632
+ const warmupBody = buildThinkingWarmupBody(typeof prepared.init.body === "string"
1633
+ ? prepared.init.body
1634
+ : undefined, Boolean(prepared.effectiveModel
1635
+ ?.toLowerCase()
1636
+ .includes("claude") &&
1637
+ prepared.effectiveModel
1638
+ ?.toLowerCase()
1639
+ .includes("thinking")));
1640
+ if (!warmupBody) {
1641
+ return;
1642
+ }
1643
+ const warmupUrl = toWarmupStreamUrl(prepared.request);
1644
+ const warmupHeaders = new Headers(prepared.init.headers ?? {});
1645
+ warmupHeaders.set("accept", "text/event-stream");
1646
+ const warmupInit = {
1647
+ ...prepared.init,
1648
+ method: prepared.init.method ?? "POST",
1649
+ headers: warmupHeaders,
1650
+ body: warmupBody,
1651
+ };
1652
+ const warmupDebugContext = startAntigravityDebugRequest({
1653
+ originalUrl: warmupUrl,
1654
+ resolvedUrl: warmupUrl,
1655
+ method: warmupInit.method,
1656
+ headers: warmupHeaders,
1657
+ body: warmupBody,
1658
+ streaming: true,
1659
+ projectId,
1660
+ });
1661
+ try {
1662
+ pushDebug("thinking-warmup: start");
1663
+ const warmupResponse = await fetch(warmupUrl, warmupInit);
1664
+ const transformed = await transformAntigravityResponse(warmupResponse, true, warmupDebugContext, prepared.requestedModel, projectId, warmupUrl, prepared.effectiveModel, prepared.sessionId);
1665
+ await transformed.text();
1666
+ markWarmupSuccess(prepared.sessionId);
1667
+ pushDebug("thinking-warmup: done");
1668
+ }
1669
+ catch (error) {
1670
+ clearWarmupAttempt(prepared.sessionId);
1671
+ pushDebug(`thinking-warmup: failed ${error instanceof Error ? error.message : String(error)}`);
1672
+ }
1673
+ };
1674
+ // Try endpoint fallbacks with single header style based on model suffix
1675
+ let shouldSwitchAccount = false;
1676
+ // Determine header style from model suffix:
1677
+ // - Models with antigravity- prefix -> use Antigravity quota
1678
+ // - Gemini models without explicit prefix -> follow cli_first
1679
+ // - Claude models -> always use Antigravity
1680
+ let headerStyle = preferredHeaderStyle;
1681
+ pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`);
1682
+ if (account.fingerprint) {
1683
+ pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`);
1684
+ }
1685
+ // Check if this header style is rate-limited for this account
1686
+ if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) {
1687
+ // Antigravity-first fallback: exhaust antigravity across ALL accounts before gemini-cli
1688
+ if (allowQuotaFallback &&
1689
+ family === "gemini" &&
1690
+ headerStyle === "antigravity") {
1691
+ // Check if ANY other account has antigravity available
1692
+ if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) {
1693
+ // Switch to another account with antigravity (preserve antigravity priority)
1694
+ pushDebug(`antigravity rate-limited on account ${account.index}, but available on other accounts. Switching.`);
1695
+ shouldSwitchAccount = true;
1696
+ }
1697
+ else {
1698
+ // All accounts exhausted antigravity - fall back to gemini-cli on this account
1699
+ const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
1700
+ const fallbackStyle = resolveQuotaFallbackHeaderStyle({
1701
+ family,
1702
+ headerStyle,
1703
+ alternateStyle,
1704
+ });
1705
+ if (fallbackStyle) {
1706
+ await showToast(`Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`, "warning");
1707
+ headerStyle = fallbackStyle;
1708
+ pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`);
1709
+ }
1710
+ else {
1711
+ shouldSwitchAccount = true;
1712
+ }
1713
+ }
1714
+ }
1715
+ else if (allowQuotaFallback && family === "gemini") {
1716
+ // gemini-cli rate-limited - try alternate style (antigravity) on same account
1717
+ const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
1718
+ const fallbackStyle = resolveQuotaFallbackHeaderStyle({
1719
+ family,
1720
+ headerStyle,
1721
+ alternateStyle,
1722
+ });
1723
+ if (fallbackStyle) {
1724
+ const quotaName = headerStyle === "gemini-cli"
1725
+ ? "Gemini CLI"
1726
+ : "Antigravity";
1727
+ const altQuotaName = fallbackStyle === "gemini-cli"
1728
+ ? "Gemini CLI"
1729
+ : "Antigravity";
1730
+ await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning");
1731
+ headerStyle = fallbackStyle;
1732
+ pushDebug(`quota fallback: ${headerStyle}`);
1733
+ }
1734
+ else {
1735
+ shouldSwitchAccount = true;
1736
+ }
1737
+ }
1738
+ else {
1739
+ shouldSwitchAccount = true;
1740
+ }
1741
+ }
1742
+ let isCapacityExhausted = false;
1743
+ while (!shouldSwitchAccount) {
1744
+ // Flag to force thinking recovery on retry after API error
1745
+ let forceThinkingRecovery = false;
1746
+ // Track if token was consumed (for hybrid strategy refund on error)
1747
+ let tokenConsumed = false;
1748
+ // Track capacity retries per endpoint to prevent infinite loops
1749
+ let capacityRetryCount = 0;
1750
+ let lastEndpointIndex = -1;
1751
+ let hasRetriedThinkingStrip = false;
1752
+ let hasRetriedContextOverflow = false;
1753
+ for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
1754
+ // Reset capacity retry counter when switching to a new endpoint
1755
+ if (i !== lastEndpointIndex) {
1756
+ capacityRetryCount = 0;
1757
+ lastEndpointIndex = i;
1758
+ }
1759
+ const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
1760
+ // Skip sandbox endpoints for Gemini CLI models - they only work with Antigravity quota
1761
+ // Gemini CLI models must use production endpoint (cloudcode-pa.googleapis.com)
1762
+ if (headerStyle === "gemini-cli" &&
1763
+ currentEndpoint !== ANTIGRAVITY_ENDPOINT_PROD) {
1764
+ pushDebug(`Skipping sandbox endpoint ${currentEndpoint} for gemini-cli headerStyle`);
1765
+ continue;
1766
+ }
1767
+ try {
1768
+ let prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, {
1769
+ claudeToolHardening: config.claude_tool_hardening,
1770
+ fingerprint: account.fingerprint,
1771
+ }, cachedModelLimits);
1772
+ // Proactive context overflow guard: return synthetic error before wasting a round-trip
1773
+ if (prepared.contextOverflowResponse) {
1774
+ if (!(config.toast_scope === "root_only" && isChildSession)) {
1775
+ await client.tui.showToast({
1776
+ body: {
1777
+ title: "Auto-Compacting Context",
1778
+ message: "Context too long. Auto-compacting now...",
1779
+ variant: "warning",
1780
+ },
1781
+ }).catch(() => { });
1782
+ }
1783
+ if (currentOpenCodeSessionId) {
1784
+ return createContextOverflowSyntheticResponse({
1785
+ client,
1786
+ directory,
1787
+ sessionId: currentOpenCodeSessionId,
1788
+ requestedModel: prepared.requestedModel,
1789
+ effectiveModel: prepared.effectiveModel,
1790
+ });
1791
+ }
1792
+ return createContextOverflowSyntheticResponse({
1793
+ client,
1794
+ directory,
1795
+ requestedModel: prepared.requestedModel,
1796
+ effectiveModel: prepared.effectiveModel,
1797
+ });
1798
+ }
1799
+ const originalUrl = toUrlString(input);
1800
+ const resolvedUrl = toUrlString(prepared.request);
1801
+ pushDebug(`endpoint=${currentEndpoint}`);
1802
+ pushDebug(`resolved=${resolvedUrl}`);
1803
+ const debugContext = startAntigravityDebugRequest({
1804
+ originalUrl,
1805
+ resolvedUrl,
1806
+ method: prepared.init.method,
1807
+ headers: prepared.init.headers,
1808
+ body: prepared.init.body,
1809
+ streaming: prepared.streaming,
1810
+ projectId: projectContext.effectiveProjectId,
1811
+ });
1812
+ await runThinkingWarmup(prepared, projectContext.effectiveProjectId);
1813
+ if (config.request_jitter_max_ms > 0) {
1814
+ const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms);
1815
+ if (jitterMs > 0) {
1816
+ await sleep(jitterMs, abortSignal);
1817
+ }
1818
+ }
1819
+ // Consume token for hybrid strategy
1820
+ // Refunded later if request fails (429 or network error)
1821
+ if (config.account_selection_strategy === "hybrid") {
1822
+ tokenConsumed = getTokenTracker().consume(account.index);
1823
+ }
1824
+ let response = await fetch(prepared.request, prepared.init);
1825
+ pushDebug(`status=${response.status} ${response.statusText}`);
1826
+ // Sonnet 4.6 Thinking fallback:
1827
+ // Some Antigravity backends no longer expose claude-sonnet-4-6-thinking.
1828
+ // If 404 is returned, retry immediately with non-thinking claude-sonnet-4-6.
1829
+ if (response.status === 404 &&
1830
+ headerStyle === "antigravity" &&
1831
+ prepared.effectiveModel?.toLowerCase() === "claude-sonnet-4-6-thinking") {
1832
+ const previousResolvedUrl = toUrlString(prepared.request);
1833
+ const fallbackInput = previousResolvedUrl.replace(/claude-sonnet-4-6-thinking/gi, "claude-sonnet-4-6");
1834
+ if (fallbackInput !== previousResolvedUrl) {
1835
+ pushDebug("sonnet-4-6 fallback: 404 on thinking model, retrying non-thinking model");
1836
+ prepared = prepareAntigravityRequest(fallbackInput, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, {
1837
+ claudeToolHardening: config.claude_tool_hardening,
1838
+ fingerprint: account.fingerprint,
1839
+ }, cachedModelLimits);
1840
+ response = await fetch(prepared.request, prepared.init);
1841
+ pushDebug(`status=${response.status} ${response.statusText} (sonnet fallback)`);
1842
+ }
1843
+ }
1844
+ // Handle 429 rate limit (or Service Overloaded) with improved logic
1845
+ if (response.status === 429 ||
1846
+ response.status === 503 ||
1847
+ response.status === 529) {
1848
+ // Refund token on rate limit
1849
+ if (tokenConsumed) {
1850
+ getTokenTracker().refund(account.index);
1851
+ tokenConsumed = false;
1852
+ }
1853
+ const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000;
1854
+ const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000;
1855
+ const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs);
1856
+ const bodyInfo = await extractRetryInfoFromBody(response);
1857
+ const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs;
1858
+ // [Enhanced Parsing] Pass status to handling logic
1859
+ const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message, response.status);
1860
+ // STRATEGY 1: CAPACITY / SERVER ERROR (Transient)
1861
+ // Goal: Wait and Retry SAME Account. DO NOT LOCK.
1862
+ // We handle this FIRST to avoid calling getRateLimitBackoff() and polluting the global rate limit state for transient errors.
1863
+ if (rateLimitReason === "MODEL_CAPACITY_EXHAUSTED" ||
1864
+ rateLimitReason === "SERVER_ERROR") {
1865
+ // Exponential backoff with jitter for capacity errors: 1s → 2s → 4s → 8s (max)
1866
+ // Matches Antigravity-Manager's ExponentialBackoff(1s, 8s)
1867
+ const baseDelayMs = 1000;
1868
+ const maxDelayMs = 8000;
1869
+ const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs);
1870
+ // Add ±10% jitter to prevent thundering herd
1871
+ const jitter = exponentialDelay * (0.9 + Math.random() * 0.2);
1872
+ const waitMs = Math.round(jitter);
1873
+ const waitSec = Math.round(waitMs / 1000);
1874
+ pushDebug(`Server busy (${rateLimitReason}) on account ${account.index}, exponential backoff ${waitMs}ms (attempt ${capacityRetryCount + 1})`);
1875
+ await showToast(`⏳ Server busy (${response.status}). Retrying in ${waitSec}s...`, "warning");
1876
+ await sleep(waitMs, abortSignal);
1877
+ // CRITICAL FIX: Decrement i so that the loop 'continue' retries the SAME endpoint index
1878
+ // (i++ in the loop will bring it back to the current index)
1879
+ // But limit retries to prevent infinite loops (Greptile feedback)
1880
+ if (capacityRetryCount < 3) {
1881
+ capacityRetryCount++;
1882
+ i -= 1;
1883
+ continue;
1884
+ }
1885
+ else {
1886
+ const remainingEndpoints = ANTIGRAVITY_ENDPOINT_FALLBACKS.slice(i + 1).filter((ep) => headerStyle !== "gemini-cli" ||
1887
+ ep === ANTIGRAVITY_ENDPOINT_PROD);
1888
+ if (remainingEndpoints.length === 0) {
1889
+ pushDebug(`All capacity retries exhausted across all endpoints for ${rateLimitReason}. Will return 503 for fallback_models.`);
1890
+ isCapacityExhausted = true;
1891
+ lastFailure = {
1892
+ response,
1893
+ streaming: prepared.streaming,
1894
+ debugContext,
1895
+ requestedModel: prepared.requestedModel,
1896
+ projectId: prepared.projectId,
1897
+ endpoint: prepared.endpoint,
1898
+ effectiveModel: prepared.effectiveModel,
1899
+ sessionId: prepared.sessionId,
1900
+ toolDebugMissing: prepared.toolDebugMissing,
1901
+ toolDebugSummary: prepared.toolDebugSummary,
1902
+ toolDebugPayload: prepared.toolDebugPayload,
1903
+ };
1904
+ break;
1905
+ }
1906
+ else {
1907
+ pushDebug(`Max capacity retries (3) exhausted for endpoint ${currentEndpoint}, trying next endpoint...`);
1908
+ const newFingerprint = accountManager.regenerateAccountFingerprint(account.index);
1909
+ if (newFingerprint) {
1910
+ pushDebug(`Fingerprint regenerated for account ${account.index}`);
1911
+ }
1912
+ continue;
1913
+ }
1914
+ }
1915
+ }
1916
+ // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN
1917
+ // Goal: Lock and Rotate (Standard Logic)
1918
+ // Only now do we call getRateLimitBackoff, which increments the global failure tracker
1919
+ const quotaKey = headerStyleToQuotaKey(headerStyle, family);
1920
+ const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs);
1921
+ // Calculate potential backoffs
1922
+ const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs);
1923
+ const effectiveDelayMs = Math.max(delayMs, smartBackoffMs);
1924
+ pushDebug(`429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`);
1925
+ if (bodyInfo.message) {
1926
+ pushDebug(`429 message=${bodyInfo.message}`);
1927
+ }
1928
+ if (bodyInfo.quotaResetTime) {
1929
+ pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`);
1930
+ }
1931
+ if (bodyInfo.reason) {
1932
+ pushDebug(`429 reason=${bodyInfo.reason}`);
1933
+ }
1934
+ logRateLimitEvent(account.index, account.email, family, response.status, effectiveDelayMs, bodyInfo);
1935
+ await logResponseBody(debugContext, response, 429);
1936
+ getHealthTracker().recordRateLimit(account.index);
1937
+ const accountLabel = account.email || `Account ${account.index + 1}`;
1938
+ // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same
1939
+ if (attempt === 1 &&
1940
+ rateLimitReason !== "QUOTA_EXHAUSTED") {
1941
+ await showToast(`Rate limited. Quick retry in 1s...`, "warning");
1942
+ await sleep(FIRST_RETRY_DELAY_MS, abortSignal);
1943
+ // CacheFirst mode: wait for same account if within threshold (preserves prompt cache)
1944
+ if (config.scheduling_mode === "cache_first") {
1945
+ const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000;
1946
+ // effectiveDelayMs is the backoff calculated for this account
1947
+ if (effectiveDelayMs <= maxCacheFirstWaitMs) {
1948
+ pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`);
1949
+ await showToast(`⏳ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, "info");
1950
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);
1951
+ await sleep(effectiveDelayMs, abortSignal);
1952
+ // Retry same endpoint after wait
1953
+ i -= 1;
1954
+ continue;
1955
+ }
1956
+ // Wait time exceeds threshold, fall through to switch
1957
+ pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`);
1958
+ }
1959
+ if (config.switch_on_first_rate_limit &&
1960
+ accountCount > 1) {
1961
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);
1962
+ shouldSwitchAccount = true;
1963
+ break;
1964
+ }
1965
+ // Same endpoint retry for first RPM hit
1966
+ i -= 1;
1967
+ continue;
1968
+ }
1969
+ accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);
1970
+ accountManager.requestSaveToDisk();
1971
+ // For Gemini, preserve preferred quota across accounts before fallback
1972
+ if (family === "gemini") {
1973
+ if (headerStyle === "antigravity") {
1974
+ // Check if any other account has Antigravity quota for this model
1975
+ if (hasOtherAccountWithAntigravity(account)) {
1976
+ pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`);
1977
+ await showToast(`Rate limited again. Switching account in 5s...`, "warning");
1978
+ await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal);
1979
+ shouldSwitchAccount = true;
1980
+ break;
1981
+ }
1982
+ // All accounts exhausted for Antigravity on THIS model.
1983
+ // Before falling back to gemini-cli, check if it's the last option (automatic fallback)
1984
+ if (allowQuotaFallback) {
1985
+ const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
1986
+ const fallbackStyle = resolveQuotaFallbackHeaderStyle({
1987
+ family,
1988
+ headerStyle,
1989
+ alternateStyle,
1990
+ });
1991
+ if (fallbackStyle) {
1992
+ const safeModelName = model || "this model";
1993
+ await showToast(`Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, "warning");
1994
+ headerStyle = fallbackStyle;
1995
+ pushDebug(`quota fallback: ${headerStyle}`);
1996
+ continue;
1997
+ }
1998
+ }
1999
+ }
2000
+ else if (headerStyle === "gemini-cli") {
2001
+ if (allowQuotaFallback) {
2002
+ const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
2003
+ const fallbackStyle = resolveQuotaFallbackHeaderStyle({
2004
+ family,
2005
+ headerStyle,
2006
+ alternateStyle,
2007
+ });
2008
+ if (fallbackStyle) {
2009
+ const safeModelName = model || "this model";
2010
+ await showToast(`Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`, "warning");
2011
+ headerStyle = fallbackStyle;
2012
+ pushDebug(`quota fallback: ${headerStyle}`);
2013
+ continue;
2014
+ }
2015
+ }
2016
+ }
2017
+ }
2018
+ const quotaName = headerStyle === "antigravity"
2019
+ ? "Antigravity"
2020
+ : "Gemini CLI";
2021
+ if (accountCount > 1) {
2022
+ const quotaMsg = bodyInfo.quotaResetTime
2023
+ ? ` (quota resets ${bodyInfo.quotaResetTime})`
2024
+ : ``;
2025
+ await showToast(`Rate limited again. Switching account in 5s...${quotaMsg}`, "warning");
2026
+ await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal);
2027
+ lastFailure = {
2028
+ response,
2029
+ streaming: prepared.streaming,
2030
+ debugContext,
2031
+ requestedModel: prepared.requestedModel,
2032
+ projectId: prepared.projectId,
2033
+ endpoint: prepared.endpoint,
2034
+ effectiveModel: prepared.effectiveModel,
2035
+ sessionId: prepared.sessionId,
2036
+ toolDebugMissing: prepared.toolDebugMissing,
2037
+ toolDebugSummary: prepared.toolDebugSummary,
2038
+ toolDebugPayload: prepared.toolDebugPayload,
2039
+ };
2040
+ shouldSwitchAccount = true;
2041
+ break;
2042
+ }
2043
+ else {
2044
+ // Single account: exponential backoff (1s, 2s, 4s, 8s... max 60s)
2045
+ const expBackoffMs = Math.min(FIRST_RETRY_DELAY_MS * Math.pow(2, attempt - 1), 60000);
2046
+ const expBackoffFormatted = expBackoffMs >= 1000
2047
+ ? `${Math.round(expBackoffMs / 1000)}s`
2048
+ : `${expBackoffMs}ms`;
2049
+ await showToast(`Rate limited. Retrying in ${expBackoffFormatted} (attempt ${attempt})...`, "warning");
2050
+ lastFailure = {
2051
+ response,
2052
+ streaming: prepared.streaming,
2053
+ debugContext,
2054
+ requestedModel: prepared.requestedModel,
2055
+ projectId: prepared.projectId,
2056
+ endpoint: prepared.endpoint,
2057
+ effectiveModel: prepared.effectiveModel,
2058
+ sessionId: prepared.sessionId,
2059
+ toolDebugMissing: prepared.toolDebugMissing,
2060
+ toolDebugSummary: prepared.toolDebugSummary,
2061
+ toolDebugPayload: prepared.toolDebugPayload,
2062
+ };
2063
+ await sleep(expBackoffMs, abortSignal);
2064
+ shouldSwitchAccount = true;
2065
+ break;
2066
+ }
2067
+ }
2068
+ // Success - reset rate limit backoff state for this quota
2069
+ const quotaKey = headerStyleToQuotaKey(headerStyle, family);
2070
+ resetRateLimitState(account.index, quotaKey);
2071
+ resetAccountFailureState(account.index);
2072
+ if (response.status === 403) {
2073
+ const errorBodyText = await response
2074
+ .clone()
2075
+ .text()
2076
+ .catch(() => "");
2077
+ const extracted = extractVerificationErrorDetails(errorBodyText);
2078
+ if (extracted.validationRequired) {
2079
+ const verificationReason = extracted.message ??
2080
+ "Google requires account verification.";
2081
+ const cooldownMs = 10 * 60 * 1000;
2082
+ accountManager.markAccountVerificationRequired(account.index, verificationReason, extracted.verifyUrl);
2083
+ accountManager.markAccountCoolingDown(account, cooldownMs, "validation-required");
2084
+ accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model);
2085
+ const label = account.email || `Account ${account.index + 1}`;
2086
+ if (accountManager.shouldShowAccountToast(account.index, 60000)) {
2087
+ await showToast(`⚠ ${label} needs verification. Run 'opencode auth login' and use Verify accounts.`, "warning");
2088
+ accountManager.markToastShown(account.index);
2089
+ }
2090
+ pushDebug(`verification-required: disabled account ${account.index}`);
2091
+ getHealthTracker().recordFailure(account.index);
2092
+ lastFailure = {
2093
+ response,
2094
+ streaming: prepared.streaming,
2095
+ debugContext,
2096
+ requestedModel: prepared.requestedModel,
2097
+ projectId: prepared.projectId,
2098
+ endpoint: prepared.endpoint,
2099
+ effectiveModel: prepared.effectiveModel,
2100
+ sessionId: prepared.sessionId,
2101
+ toolDebugMissing: prepared.toolDebugMissing,
2102
+ toolDebugSummary: prepared.toolDebugSummary,
2103
+ toolDebugPayload: prepared.toolDebugPayload,
2104
+ };
2105
+ shouldSwitchAccount = true;
2106
+ break;
2107
+ }
2108
+ }
2109
+ const shouldRetryEndpoint = response.status === 403 ||
2110
+ response.status === 404 ||
2111
+ response.status >= 500;
2112
+ if (shouldRetryEndpoint) {
2113
+ await logResponseBody(debugContext, response, response.status);
2114
+ }
2115
+ if (shouldRetryEndpoint &&
2116
+ i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
2117
+ lastFailure = {
2118
+ response,
2119
+ streaming: prepared.streaming,
2120
+ debugContext,
2121
+ requestedModel: prepared.requestedModel,
2122
+ projectId: prepared.projectId,
2123
+ endpoint: prepared.endpoint,
2124
+ effectiveModel: prepared.effectiveModel,
2125
+ sessionId: prepared.sessionId,
2126
+ toolDebugMissing: prepared.toolDebugMissing,
2127
+ toolDebugSummary: prepared.toolDebugSummary,
2128
+ toolDebugPayload: prepared.toolDebugPayload,
2129
+ };
2130
+ continue;
2131
+ }
2132
+ // Success or non-retryable error - return the response
2133
+ if (response.ok) {
2134
+ account.consecutiveFailures = 0;
2135
+ getHealthTracker().recordSuccess(account.index);
2136
+ accountManager.markAccountUsed(account.index);
2137
+ void triggerAsyncQuotaRefreshForAccount(accountManager, account.index, client, providerId, config.quota_refresh_interval_minutes, directory);
2138
+ }
2139
+ logAntigravityDebugResponse(debugContext, response, {
2140
+ note: response.ok
2141
+ ? "Success"
2142
+ : `Error ${response.status}`,
2143
+ });
2144
+ if (!response.ok) {
2145
+ await logResponseBody(debugContext, response, response.status);
2146
+ if (response.status === 400) {
2147
+ const cloned = response.clone();
2148
+ const bodyText = await cloned.text();
2149
+ const bodyTextLower = bodyText.toLowerCase();
2150
+ const isContextOverflow = bodyTextLower.includes("prompt is too long") ||
2151
+ bodyTextLower.includes("prompt_too_long") ||
2152
+ bodyTextLower.includes("context length exceeded") ||
2153
+ bodyTextLower.includes("context_length_exceeded");
2154
+ const parsedLimit = isContextOverflow
2155
+ ? parseContextLimitFromError(bodyText)
2156
+ : null;
2157
+ if (parsedLimit?.maxTokens) {
2158
+ updateLearnedLimit(family, parsedLimit.maxTokens);
2159
+ pushDebug(`learned context limit ${family}=${parsedLimit.maxTokens}`);
2160
+ }
2161
+ if (isContextOverflow &&
2162
+ !hasRetriedContextOverflow &&
2163
+ currentOpenCodeSessionId) {
2164
+ hasRetriedContextOverflow = true;
2165
+ await showToast("Context too long. Auto-compacting and retrying once...", "warning");
2166
+ let compactFailed = false;
2167
+ try {
2168
+ await runAutoCompactCommand(client, currentOpenCodeSessionId, directory, AUTO_COMPACT_TIMEOUT_MS);
2169
+ }
2170
+ catch {
2171
+ compactFailed = true;
2172
+ }
2173
+ if (!compactFailed) {
2174
+ const retryResponse = await fetch(prepared.request, prepared.init);
2175
+ if (retryResponse.ok || retryResponse.status !== 400) {
2176
+ return transformAntigravityResponse(retryResponse, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload, debugLines);
2177
+ }
2178
+ const retryBodyText = await retryResponse.clone().text();
2179
+ const retryParsedLimit = parseContextLimitFromError(retryBodyText);
2180
+ if (retryParsedLimit?.maxTokens) {
2181
+ updateLearnedLimit(family, retryParsedLimit.maxTokens);
2182
+ }
2183
+ }
2184
+ const limitHint = parsedLimit?.maxTokens
2185
+ ? ` Learned limit: ${parsedLimit.maxTokens.toLocaleString()} tokens.`
2186
+ : "";
2187
+ const actualHint = parsedLimit?.actualTokens
2188
+ ? ` Last request was about ${parsedLimit.actualTokens.toLocaleString()} tokens.`
2189
+ : "";
2190
+ const compactHint = compactFailed
2191
+ ? " Auto-compact failed or timed out."
2192
+ : " Auto-compact succeeded but retry is still over the model context limit.";
2193
+ return createSyntheticErrorResponse(`[Antigravity Error] Context is still too long after automatic recovery.${compactHint}${limitHint}${actualHint}\n\nRun /compact manually, then resend your last message.`, prepared.requestedModel);
2194
+ }
2195
+ if (isContextOverflow) {
2196
+ const limitHint = parsedLimit?.maxTokens
2197
+ ? ` Learned limit: ${parsedLimit.maxTokens.toLocaleString()} tokens.`
2198
+ : "";
2199
+ const actualHint = parsedLimit?.actualTokens
2200
+ ? ` Last request was about ${parsedLimit.actualTokens.toLocaleString()} tokens.`
2201
+ : "";
2202
+ return createSyntheticErrorResponse(`[Antigravity Error] Context is too long for this model.${limitHint}${actualHint}\n\nUse /compact to reduce context size, then retry your request.`, prepared.requestedModel);
2203
+ }
2204
+ }
2205
+ }
2206
+ // Empty response retry logic (ported from LLM-API-Key-Proxy)
2207
+ // For non-streaming responses, check if the response body is empty
2208
+ // and retry if so (up to config.empty_response_max_attempts times)
2209
+ if (response.ok && !prepared.streaming) {
2210
+ const maxAttempts = config.empty_response_max_attempts ?? 4;
2211
+ const retryDelayMs = config.empty_response_retry_delay_ms ?? 2000;
2212
+ // Clone to check body without consuming original
2213
+ const clonedForCheck = response.clone();
2214
+ const bodyText = await clonedForCheck.text();
2215
+ if (isEmptyResponseBody(bodyText)) {
2216
+ // Track empty response attempts per request
2217
+ const emptyAttemptKey = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`;
2218
+ const currentAttempts = (emptyResponseAttempts.get(emptyAttemptKey) ?? 0) +
2219
+ 1;
2220
+ emptyResponseAttempts.set(emptyAttemptKey, currentAttempts);
2221
+ pushDebug(`empty-response: attempt ${currentAttempts}/${maxAttempts}`);
2222
+ if (currentAttempts < maxAttempts) {
2223
+ await showToast(`Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`, "warning");
2224
+ await sleep(retryDelayMs, abortSignal);
2225
+ continue; // Retry the endpoint loop
2226
+ }
2227
+ // Clean up and throw after max attempts
2228
+ emptyResponseAttempts.delete(emptyAttemptKey);
2229
+ throw new EmptyResponseError("antigravity", prepared.effectiveModel ?? "unknown", currentAttempts);
2230
+ }
2231
+ // Clean up successful attempt tracking
2232
+ const emptyAttemptKeyClean = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`;
2233
+ emptyResponseAttempts.delete(emptyAttemptKeyClean);
2234
+ }
2235
+ 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);
2236
+ if (transformedResponse.headers.get("x-antigravity-thinking-recovery") === "needed" &&
2237
+ !hasRetriedThinkingStrip) {
2238
+ hasRetriedThinkingStrip = true;
2239
+ log.debug("Silent retry: stripping thinking blocks and retrying request");
2240
+ if (typeof prepared.init.body === "string") {
2241
+ try {
2242
+ const retryPayload = JSON.parse(prepared.init.body);
2243
+ if (retryPayload &&
2244
+ typeof retryPayload === "object" &&
2245
+ !Array.isArray(retryPayload)) {
2246
+ const retryPayloadBody = retryPayload;
2247
+ if (Array.isArray(retryPayloadBody.contents)) {
2248
+ retryPayloadBody.contents =
2249
+ filterThinkingFromHistory(retryPayloadBody.contents, false);
2250
+ }
2251
+ const retryInit = {
2252
+ ...prepared.init,
2253
+ body: JSON.stringify(retryPayloadBody),
2254
+ };
2255
+ const retryResponse = await fetch(prepared.request, retryInit);
2256
+ if (retryResponse.ok ||
2257
+ retryResponse.status !== 400) {
2258
+ return transformAntigravityResponse(retryResponse, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload, debugLines);
2259
+ }
2260
+ }
2261
+ }
2262
+ catch {
2263
+ pushDebug("thinking-recovery: failed to parse request body for silent retry");
2264
+ }
2265
+ }
2266
+ }
2267
+ // Check for context errors and show appropriate toast
2268
+ const contextError = transformedResponse.headers.get("x-antigravity-context-error");
2269
+ if (contextError) {
2270
+ if (contextError === "prompt_too_long") {
2271
+ await showToast("Context too long - use /compact to reduce size, or trim your request", "warning");
2272
+ }
2273
+ else if (contextError === "tool_pairing") {
2274
+ await showToast("Tool call/result mismatch - use /compact to fix, or /undo last message", "warning");
2275
+ }
2276
+ }
2277
+ return transformedResponse;
2278
+ }
2279
+ catch (error) {
2280
+ // Refund token on network/API error (only if consumed)
2281
+ if (tokenConsumed) {
2282
+ getTokenTracker().refund(account.index);
2283
+ tokenConsumed = false;
2284
+ }
2285
+ if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
2286
+ lastError =
2287
+ error instanceof Error
2288
+ ? error
2289
+ : new Error(String(error));
2290
+ continue;
2291
+ }
2292
+ // All endpoints failed for this account - track failure and try next account
2293
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
2294
+ lastError =
2295
+ error instanceof Error
2296
+ ? error
2297
+ : new Error(String(error));
2298
+ if (shouldCooldown) {
2299
+ accountManager.markAccountCoolingDown(account, cooldownMs, "network-error");
2300
+ accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model);
2301
+ pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`);
2302
+ }
2303
+ shouldSwitchAccount = true;
2304
+ break;
2305
+ }
2306
+ }
2307
+ if (isCapacityExhausted)
2308
+ break;
2309
+ } // end headerStyleLoop
2310
+ if (isCapacityExhausted && lastFailure) {
2311
+ pushDebug(`Capacity exhausted for model ${model}. Returning 503 to trigger fallback_models.`);
2312
+ await showToast(`🔄 ${model} capacity exhausted. Triggering model fallback...`, "warning");
2313
+ return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines);
2314
+ }
2315
+ if (shouldSwitchAccount) {
2316
+ // Avoid tight retry loops when there's only one account.
2317
+ if (accountCount <= 1) {
2318
+ if (lastFailure) {
2319
+ return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines);
2320
+ }
2321
+ throw (lastError || new Error("All Antigravity endpoints failed"));
2322
+ }
2323
+ continue;
2324
+ }
2325
+ // If we get here without returning, something went wrong
2326
+ if (lastFailure) {
2327
+ return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines);
2328
+ }
2329
+ throw lastError || new Error("All Antigravity accounts failed");
2330
+ }
2331
+ },
2332
+ };
2333
+ },
2334
+ methods: [
2335
+ {
2336
+ label: "OAuth with Google (Antigravity)",
2337
+ type: "oauth",
2338
+ authorize: async (inputs) => {
2339
+ const isHeadless = !!(process.env.SSH_CONNECTION ||
2340
+ process.env.SSH_CLIENT ||
2341
+ process.env.SSH_TTY ||
2342
+ process.env.OPENCODE_HEADLESS);
2343
+ // CLI flow (`opencode auth login`) passes an inputs object.
2344
+ if (inputs) {
2345
+ const accounts = [];
2346
+ const noBrowser = inputs.noBrowser === "true" ||
2347
+ inputs["no-browser"] === "true";
2348
+ const useManualMode = noBrowser || shouldSkipLocalServer();
2349
+ // Check for existing accounts and prompt user for login mode
2350
+ let startFresh = true;
2351
+ let refreshAccountIndex;
2352
+ const existingStorage = await loadAccounts();
2353
+ if (existingStorage && existingStorage.accounts.length > 0) {
2354
+ let menuResult;
2355
+ while (true) {
2356
+ const now = Date.now();
2357
+ const existingAccounts = existingStorage.accounts.map((acc, idx) => {
2358
+ let status = "unknown";
2359
+ let rateLimitedFamilies;
2360
+ let rateLimitResetIn;
2361
+ if (acc.verificationRequired) {
2362
+ status = "verification-required";
2363
+ }
2364
+ else {
2365
+ // Build rate-limit state from rateLimitResetTimes (429-based)
2366
+ const familyResets = {};
2367
+ const rateLimits = acc.rateLimitResetTimes;
2368
+ if (rateLimits) {
2369
+ for (const [key, resetTime] of Object.entries(rateLimits)) {
2370
+ if (typeof resetTime === "number" &&
2371
+ resetTime > now) {
2372
+ const family = key === "claude" ? "claude" : "gemini";
2373
+ const remainingMs = resetTime - now;
2374
+ if (!familyResets[family] ||
2375
+ remainingMs < familyResets[family]) {
2376
+ familyResets[family] = remainingMs;
2377
+ }
2378
+ }
2379
+ }
2380
+ }
2381
+ // Also derive from cachedQuota (quota-check-based)
2382
+ // This ensures badges are accurate even without 429 errors
2383
+ if (acc.cachedQuota) {
2384
+ const claudeQ = acc.cachedQuota.claude ?? [];
2385
+ const claudeMinFraction = getGroupMinFraction(claudeQ);
2386
+ if (typeof claudeMinFraction === "number" &&
2387
+ claudeMinFraction <= 0 &&
2388
+ !familyResets["claude"]) {
2389
+ const claudeReset = getGroupEarliestReset(claudeQ);
2390
+ const resetMs = claudeReset
2391
+ ? Date.parse(claudeReset) - now
2392
+ : 0;
2393
+ if (resetMs > 0) {
2394
+ familyResets["claude"] = resetMs;
2395
+ }
2396
+ }
2397
+ const geminiPro = acc.cachedQuota["gemini-pro"] ?? [];
2398
+ const geminiFlash = acc.cachedQuota["gemini-flash"] ?? [];
2399
+ const geminiProMin = getGroupMinFraction(geminiPro);
2400
+ const geminiFlashMin = getGroupMinFraction(geminiFlash);
2401
+ const proExhausted = typeof geminiProMin === "number" &&
2402
+ geminiProMin <= 0;
2403
+ const flashExhausted = typeof geminiFlashMin === "number" &&
2404
+ geminiFlashMin <= 0;
2405
+ if (proExhausted &&
2406
+ flashExhausted &&
2407
+ !familyResets["gemini"]) {
2408
+ const proResetTime = getGroupEarliestReset(geminiPro);
2409
+ const flashResetTime = getGroupEarliestReset(geminiFlash);
2410
+ const proReset = proResetTime
2411
+ ? Date.parse(proResetTime) - now
2412
+ : 0;
2413
+ const flashReset = flashResetTime
2414
+ ? Date.parse(flashResetTime) - now
2415
+ : 0;
2416
+ const earliest = Math.min(proReset > 0 ? proReset : Infinity, flashReset > 0 ? flashReset : Infinity);
2417
+ if (earliest > 0 && earliest < Infinity) {
2418
+ familyResets["gemini"] = earliest;
2419
+ }
2420
+ }
2421
+ }
2422
+ if (Object.keys(familyResets).length > 0) {
2423
+ rateLimitedFamilies = Object.keys(familyResets);
2424
+ rateLimitResetIn = familyResets;
2425
+ // Only fully rate-limited if ALL families are exhausted
2426
+ const allFamiliesLimited = rateLimitedFamilies.includes("claude") &&
2427
+ rateLimitedFamilies.includes("gemini");
2428
+ status = allFamiliesLimited
2429
+ ? "rate-limited"
2430
+ : "active";
2431
+ }
2432
+ else {
2433
+ status = "active";
2434
+ }
2435
+ if (acc.coolingDownUntil &&
2436
+ acc.coolingDownUntil > now) {
2437
+ status = "rate-limited";
2438
+ }
2439
+ }
2440
+ return {
2441
+ email: acc.email,
2442
+ index: idx,
2443
+ addedAt: acc.addedAt,
2444
+ lastUsed: acc.lastUsed,
2445
+ status,
2446
+ rateLimitedFamilies,
2447
+ rateLimitResetIn,
2448
+ isCurrentAccount: idx === (existingStorage.activeIndex ?? 0),
2449
+ enabled: acc.enabled !== false,
2450
+ };
2451
+ });
2452
+ menuResult = await promptLoginMode(existingAccounts);
2453
+ if (menuResult.mode === "check") {
2454
+ console.log("\n📊 Checking quotas for all accounts...\n");
2455
+ const results = await checkAccountsQuota(existingStorage.accounts, client, providerId);
2456
+ cachedModelLimits = await readModelLimits(directory);
2457
+ let storageUpdated = false;
2458
+ for (const res of results) {
2459
+ const label = res.email || `Account ${res.index + 1}`;
2460
+ const disabledStr = res.disabled ? " (disabled)" : "";
2461
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2462
+ console.log(` ${label}${disabledStr}`);
2463
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2464
+ if (res.status === "error") {
2465
+ console.log(` ❌ Error: ${res.error}\n`);
2466
+ continue;
2467
+ }
2468
+ // ANSI color codes
2469
+ const colors = {
2470
+ red: "\x1b[31m",
2471
+ orange: "\x1b[33m", // Yellow/orange
2472
+ green: "\x1b[32m",
2473
+ reset: "\x1b[0m",
2474
+ };
2475
+ // Get color based on remaining percentage
2476
+ const getColor = (remaining) => {
2477
+ if (typeof remaining !== "number")
2478
+ return colors.reset;
2479
+ if (remaining < 0.2)
2480
+ return colors.red;
2481
+ if (remaining < 0.6)
2482
+ return colors.orange;
2483
+ return colors.green;
2484
+ };
2485
+ // Helper to create colored progress bar
2486
+ const createProgressBar = (remaining, width = 20) => {
2487
+ if (typeof remaining !== "number")
2488
+ return "░".repeat(width) + " ???";
2489
+ const filled = Math.round(remaining * width);
2490
+ const empty = width - filled;
2491
+ const color = getColor(remaining);
2492
+ const bar = `${color}${"█".repeat(filled)}${colors.reset}${"░".repeat(empty)}`;
2493
+ const pct = `${color}${Math.round(remaining * 100)}%${colors.reset}`.padStart(4 + color.length + colors.reset.length);
2494
+ return `${bar} ${pct}`;
2495
+ };
2496
+ // Helper to format reset time with days support
2497
+ const formatReset = (resetTime) => {
2498
+ if (!resetTime)
2499
+ return "";
2500
+ const resetTimestamp = typeof resetTime === "number"
2501
+ ? resetTime
2502
+ : Date.parse(resetTime);
2503
+ const ms = resetTimestamp - Date.now();
2504
+ if (ms <= 0)
2505
+ return " (resetting...)";
2506
+ const hours = ms / (1000 * 60 * 60);
2507
+ if (hours >= 24) {
2508
+ const days = Math.floor(hours / 24);
2509
+ const remainingHours = Math.floor(hours % 24);
2510
+ if (remainingHours > 0) {
2511
+ return ` (resets in ${days}d ${remainingHours}h)`;
2512
+ }
2513
+ return ` (resets in ${days}d)`;
2514
+ }
2515
+ return ` (resets in ${formatWaitTime(ms)})`;
2516
+ };
2517
+ // Display Gemini CLI Quota first (as requested - swap order)
2518
+ const hasGeminiCli = res.geminiCliQuota &&
2519
+ res.geminiCliQuota.models.length > 0;
2520
+ console.log(`\n ┌─ Gemini CLI Quota`);
2521
+ if (!hasGeminiCli) {
2522
+ const errorMsg = res.geminiCliQuota?.error ||
2523
+ "No Gemini CLI quota available";
2524
+ console.log(` │ └─ ${errorMsg}`);
2525
+ }
2526
+ else {
2527
+ const models = res.geminiCliQuota.models;
2528
+ models.forEach((model, idx) => {
2529
+ const isLast = idx === models.length - 1;
2530
+ const connector = isLast ? "└─" : "├─";
2531
+ const bar = createProgressBar(model.remainingFraction);
2532
+ const reset = formatReset(model.resetTime);
2533
+ const modelName = model.modelId.padEnd(29);
2534
+ console.log(` │ ${connector} ${modelName} ${bar}${reset}`);
2535
+ });
2536
+ }
2537
+ // Display Antigravity Quota second
2538
+ const hasAntigravity = res.quota && Object.keys(res.quota.groups).length > 0;
2539
+ console.log(` │`);
2540
+ console.log(` └─ Antigravity Quota`);
2541
+ if (!hasAntigravity) {
2542
+ const errorMsg = res.quota?.error ||
2543
+ "No quota information available";
2544
+ console.log(` └─ ${errorMsg}`);
2545
+ }
2546
+ else {
2547
+ const groups = res.quota.groups;
2548
+ const groupEntries = [
2549
+ { name: "Claude", data: groups.claude },
2550
+ {
2551
+ name: "Gemini Pro",
2552
+ data: groups["gemini-pro"],
2553
+ },
2554
+ {
2555
+ name: "Gemini Flash",
2556
+ data: groups["gemini-flash"],
2557
+ },
2558
+ ].filter((g) => g.data);
2559
+ groupEntries.forEach((g, idx) => {
2560
+ const isLastGroup = idx === groupEntries.length - 1;
2561
+ const connector = isLastGroup ? "└─" : "├─";
2562
+ const modelName = g.name.padEnd(29);
2563
+ const groupModel = g.data ?? [];
2564
+ console.log(` ${connector} ${modelName}`);
2565
+ // Jika grup terakhir pakai spasi, selainnya pakai │
2566
+ const childPrefix = isLastGroup ? " " : " │ ";
2567
+ // Cari panjang nama model terpanjang dalam grup ini untuk alignment
2568
+ const maxModelLen = Math.max(...groupModel.map((d) => d.model.length));
2569
+ groupModel.forEach((data, idx) => {
2570
+ const isLast = idx === groupModel.length - 1;
2571
+ const connector = isLast ? "└─" : "├─";
2572
+ const bar = createProgressBar(data.remainingFraction);
2573
+ const reset = formatReset(data.resetTime);
2574
+ const paddedModel = data.model.padEnd(maxModelLen);
2575
+ console.log(`${childPrefix}${connector} ${paddedModel} ${bar}${reset}`);
2576
+ });
2577
+ });
2578
+ }
2579
+ console.log("");
2580
+ // Cache quota data for soft quota protection
2581
+ if (res.quota?.groups) {
2582
+ const acc = existingStorage.accounts[res.index];
2583
+ if (acc) {
2584
+ acc.cachedQuota = Object.fromEntries(Object.entries(res.quota.groups).map(([group, entries]) => [
2585
+ group,
2586
+ entries.map((entry) => ({
2587
+ model: entry.model,
2588
+ modelName: typeof entry.displayName === "string" &&
2589
+ entry.displayName.trim().length > 0
2590
+ ? entry.displayName.trim()
2591
+ : entry.model,
2592
+ remainingFraction: entry.remainingFraction,
2593
+ resetTime: (() => {
2594
+ if (typeof entry.resetTime === "number" &&
2595
+ Number.isFinite(entry.resetTime)) {
2596
+ return entry.resetTime;
2597
+ }
2598
+ if (typeof entry.resetTime === "string") {
2599
+ const parsed = Date.parse(entry.resetTime);
2600
+ return Number.isFinite(parsed) ? parsed : undefined;
2601
+ }
2602
+ return undefined;
2603
+ })(),
2604
+ })),
2605
+ ]));
2606
+ acc.cachedQuotaUpdatedAt = Date.now();
2607
+ // Derive rateLimitResetTimes from actual quota data
2608
+ // so auth menu badges accurately reflect quota state
2609
+ if (!acc.rateLimitResetTimes) {
2610
+ acc.rateLimitResetTimes = {};
2611
+ }
2612
+ // Claude quota → rateLimitResetTimes.claude
2613
+ const claudeQuota = res.quota.groups.claude ?? [];
2614
+ if (claudeQuota.length > 0) {
2615
+ const claudeMinFraction = getGroupMinFraction(claudeQuota);
2616
+ const claudeReset = getGroupEarliestReset(claudeQuota);
2617
+ if (typeof claudeMinFraction === "number" &&
2618
+ claudeMinFraction <= 0 &&
2619
+ claudeReset) {
2620
+ acc.rateLimitResetTimes.claude = Date.parse(claudeReset);
2621
+ }
2622
+ else {
2623
+ // Quota available → clear any stale rate limit
2624
+ delete acc.rateLimitResetTimes.claude;
2625
+ }
2626
+ }
2627
+ // Gemini Pro/Flash → rateLimitResetTimes["gemini-antigravity"]
2628
+ const geminiPro = res.quota.groups["gemini-pro"] ?? [];
2629
+ const geminiFlash = res.quota.groups["gemini-flash"] ?? [];
2630
+ const geminiProMin = getGroupMinFraction(geminiPro);
2631
+ const geminiFlashMin = getGroupMinFraction(geminiFlash);
2632
+ const geminiExhausted = typeof geminiProMin === "number" &&
2633
+ geminiProMin <= 0;
2634
+ const flashExhausted = typeof geminiFlashMin === "number" &&
2635
+ geminiFlashMin <= 0;
2636
+ if (geminiExhausted && flashExhausted) {
2637
+ // Both Gemini families exhausted — use earliest reset
2638
+ const proResetTime = getGroupEarliestReset(geminiPro);
2639
+ const flashResetTime = getGroupEarliestReset(geminiFlash);
2640
+ const proReset = proResetTime
2641
+ ? Date.parse(proResetTime)
2642
+ : Infinity;
2643
+ const flashReset = flashResetTime
2644
+ ? Date.parse(flashResetTime)
2645
+ : Infinity;
2646
+ acc.rateLimitResetTimes["gemini-antigravity"] =
2647
+ Math.min(proReset, flashReset);
2648
+ }
2649
+ else {
2650
+ // At least one Gemini family has quota → clear
2651
+ delete acc.rateLimitResetTimes["gemini-antigravity"];
2652
+ }
2653
+ // Clean up empty object
2654
+ if (Object.keys(acc.rateLimitResetTimes).length === 0) {
2655
+ delete acc.rateLimitResetTimes;
2656
+ }
2657
+ storageUpdated = true;
2658
+ }
2659
+ }
2660
+ if (res.updatedAccount) {
2661
+ const currentAcc = existingStorage.accounts[res.index];
2662
+ existingStorage.accounts[res.index] = {
2663
+ ...res.updatedAccount,
2664
+ cachedQuota: res.quota?.groups
2665
+ ? Object.fromEntries(Object.entries(res.quota.groups).map(([group, entries]) => [
2666
+ group,
2667
+ entries.map((entry) => ({
2668
+ model: entry.model,
2669
+ modelName: typeof entry.displayName === "string" &&
2670
+ entry.displayName.trim().length > 0
2671
+ ? entry.displayName.trim()
2672
+ : entry.model,
2673
+ remainingFraction: entry.remainingFraction,
2674
+ resetTime: entry.resetTime
2675
+ ? (() => {
2676
+ if (typeof entry.resetTime === "number" &&
2677
+ Number.isFinite(entry.resetTime)) {
2678
+ return entry.resetTime;
2679
+ }
2680
+ if (typeof entry.resetTime !== "string") {
2681
+ return undefined;
2682
+ }
2683
+ const parsed = Date.parse(entry.resetTime);
2684
+ return Number.isFinite(parsed) ? parsed : undefined;
2685
+ })()
2686
+ : undefined,
2687
+ })),
2688
+ ]))
2689
+ : undefined,
2690
+ cachedQuotaUpdatedAt: Date.now(),
2691
+ // Preserve rate limit state derived from quota check above
2692
+ rateLimitResetTimes: currentAcc?.rateLimitResetTimes ??
2693
+ res.updatedAccount.rateLimitResetTimes,
2694
+ };
2695
+ storageUpdated = true;
2696
+ }
2697
+ }
2698
+ if (storageUpdated) {
2699
+ await saveAccounts(existingStorage);
2700
+ existingStorage.accounts = [
2701
+ ...existingStorage.accounts,
2702
+ ]; // Force refresh reference
2703
+ }
2704
+ await pause();
2705
+ console.log("");
2706
+ continue;
2707
+ }
2708
+ if (menuResult.mode === "manage") {
2709
+ if (menuResult.toggleAccountIndex !== undefined) {
2710
+ const acc = existingStorage.accounts[menuResult.toggleAccountIndex];
2711
+ if (acc) {
2712
+ acc.enabled = acc.enabled === false;
2713
+ await saveAccounts(existingStorage);
2714
+ activeAccountManager?.setAccountEnabled(menuResult.toggleAccountIndex, acc.enabled);
2715
+ console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? "enabled" : "disabled"}.\n`);
2716
+ }
2717
+ }
2718
+ continue;
2719
+ }
2720
+ if (menuResult.mode === "verify" ||
2721
+ menuResult.mode === "verify-all") {
2722
+ const verifyAll = menuResult.mode === "verify-all" ||
2723
+ menuResult.verifyAll === true;
2724
+ if (verifyAll) {
2725
+ if (existingStorage.accounts.length === 0) {
2726
+ console.log("\nNo accounts available to verify.\n");
2727
+ continue;
2728
+ }
2729
+ console.log(`\nChecking verification status for ${existingStorage.accounts.length} account(s)...\n`);
2730
+ let okCount = 0;
2731
+ let blockedCount = 0;
2732
+ let errorCount = 0;
2733
+ let storageUpdated = false;
2734
+ const blockedResults = [];
2735
+ for (let i = 0; i < existingStorage.accounts.length; i++) {
2736
+ const account = existingStorage.accounts[i];
2737
+ if (!account)
2738
+ continue;
2739
+ const label = account.email || `Account ${i + 1}`;
2740
+ process.stdout.write(`- [${i + 1}/${existingStorage.accounts.length}] ${label} ... `);
2741
+ const verification = await verifyAccountAccess(account, client, providerId);
2742
+ if (verification.status === "ok") {
2743
+ const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true);
2744
+ if (changed) {
2745
+ storageUpdated = true;
2746
+ }
2747
+ activeAccountManager?.clearAccountVerificationRequired(i, wasVerificationRequired);
2748
+ okCount += 1;
2749
+ console.log("ok");
2750
+ continue;
2751
+ }
2752
+ if (verification.status === "blocked") {
2753
+ const changed = markStoredAccountVerificationRequired(account, verification.message, verification.verifyUrl);
2754
+ if (changed) {
2755
+ storageUpdated = true;
2756
+ }
2757
+ activeAccountManager?.markAccountVerificationRequired(i, verification.message, verification.verifyUrl);
2758
+ blockedCount += 1;
2759
+ console.log("needs verification");
2760
+ const verifyUrl = verification.verifyUrl ?? account.verificationUrl;
2761
+ blockedResults.push({
2762
+ label,
2763
+ message: verification.message,
2764
+ verifyUrl,
2765
+ });
2766
+ continue;
2767
+ }
2768
+ errorCount += 1;
2769
+ console.log(`error (${verification.message})`);
2770
+ }
2771
+ if (storageUpdated) {
2772
+ await saveAccounts(existingStorage);
2773
+ }
2774
+ console.log(`\nVerification summary: ${okCount} ready, ${blockedCount} need verification, ${errorCount} errors.`);
2775
+ if (blockedResults.length > 0) {
2776
+ console.log("\nAccounts needing verification:");
2777
+ for (const result of blockedResults) {
2778
+ console.log(`\n- ${result.label}`);
2779
+ console.log(` ${result.message}`);
2780
+ if (result.verifyUrl) {
2781
+ console.log(` URL: ${result.verifyUrl}`);
2782
+ }
2783
+ else {
2784
+ console.log(" URL: not provided by API response");
2785
+ }
2786
+ }
2787
+ console.log("");
2788
+ }
2789
+ else {
2790
+ console.log("");
2791
+ }
2792
+ continue;
2793
+ }
2794
+ let verifyAccountIndex = menuResult.verifyAccountIndex;
2795
+ if (verifyAccountIndex === undefined) {
2796
+ verifyAccountIndex =
2797
+ await promptAccountIndexForVerification(existingAccounts);
2798
+ }
2799
+ if (verifyAccountIndex === undefined) {
2800
+ console.log("\nVerification cancelled.\n");
2801
+ continue;
2802
+ }
2803
+ const account = existingStorage.accounts[verifyAccountIndex];
2804
+ if (!account) {
2805
+ console.log(`\nAccount ${verifyAccountIndex + 1} not found.\n`);
2806
+ continue;
2807
+ }
2808
+ const label = account.email || `Account ${verifyAccountIndex + 1}`;
2809
+ console.log(`\nChecking verification status for ${label}...\n`);
2810
+ const verification = await verifyAccountAccess(account, client, providerId);
2811
+ if (verification.status === "ok") {
2812
+ const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true);
2813
+ if (changed) {
2814
+ await saveAccounts(existingStorage);
2815
+ }
2816
+ activeAccountManager?.clearAccountVerificationRequired(verifyAccountIndex, wasVerificationRequired);
2817
+ if (wasVerificationRequired) {
2818
+ console.log(`✓ ${label} is ready for requests and has been re-enabled.\n`);
2819
+ }
2820
+ else {
2821
+ console.log(`✓ ${label} is ready for requests.\n`);
2822
+ }
2823
+ continue;
2824
+ }
2825
+ if (verification.status === "blocked") {
2826
+ const changed = markStoredAccountVerificationRequired(account, verification.message, verification.verifyUrl);
2827
+ if (changed) {
2828
+ await saveAccounts(existingStorage);
2829
+ }
2830
+ activeAccountManager?.markAccountVerificationRequired(verifyAccountIndex, verification.message, verification.verifyUrl);
2831
+ const verifyUrl = verification.verifyUrl ?? account.verificationUrl;
2832
+ console.log(`⚠ ${label} needs Google verification before it can be used.`);
2833
+ if (verification.message) {
2834
+ console.log(verification.message);
2835
+ }
2836
+ console.log(`${label} has been disabled until verification is completed.`);
2837
+ if (verifyUrl) {
2838
+ console.log(`\nVerification URL:\n${verifyUrl}\n`);
2839
+ if (await promptOpenVerificationUrl()) {
2840
+ const opened = await openBrowser(verifyUrl);
2841
+ if (opened) {
2842
+ console.log("Opened verification URL in your browser.\n");
2843
+ }
2844
+ else {
2845
+ console.log("Could not open browser automatically. Please open the URL manually.\n");
2846
+ }
2847
+ }
2848
+ }
2849
+ else {
2850
+ console.log("No verification URL was returned. Try re-authenticating this account.\n");
2851
+ }
2852
+ continue;
2853
+ }
2854
+ console.log(`✗ ${label}: ${verification.message}\n`);
2855
+ continue;
2856
+ }
2857
+ break;
2858
+ }
2859
+ if (menuResult.mode === "cancel") {
2860
+ return {
2861
+ url: "",
2862
+ instructions: "Authentication cancelled",
2863
+ method: "auto",
2864
+ callback: async () => ({
2865
+ type: "failed",
2866
+ error: "Authentication cancelled",
2867
+ }),
2868
+ };
2869
+ }
2870
+ if (menuResult.deleteAccountIndex !== undefined) {
2871
+ const updatedAccounts = existingStorage.accounts.filter((_, idx) => idx !== menuResult.deleteAccountIndex);
2872
+ // Use saveAccountsReplace to bypass merge (otherwise deleted account gets merged back)
2873
+ await saveAccountsReplace({
2874
+ version: 4,
2875
+ accounts: updatedAccounts,
2876
+ activeIndex: 0,
2877
+ activeIndexByFamily: { claude: 0, gemini: 0 },
2878
+ });
2879
+ // Sync in-memory state so deleted account stops being used immediately
2880
+ activeAccountManager?.removeAccountByIndex(menuResult.deleteAccountIndex);
2881
+ console.log("\nAccount deleted.\n");
2882
+ if (updatedAccounts.length > 0) {
2883
+ const fallbackAccount = updatedAccounts[0];
2884
+ if (fallbackAccount?.refreshToken) {
2885
+ const fallbackResult = buildAuthSuccessFromStoredAccount(fallbackAccount);
2886
+ try {
2887
+ await client.auth.set({
2888
+ path: { id: providerId },
2889
+ body: {
2890
+ type: "oauth",
2891
+ refresh: fallbackResult.refresh,
2892
+ access: "",
2893
+ expires: 0,
2894
+ },
2895
+ });
2896
+ }
2897
+ catch (storeError) {
2898
+ log.error("Failed to update stored Antigravity OAuth credentials", { error: String(storeError) });
2899
+ }
2900
+ const label = fallbackAccount.email || `Account ${1}`;
2901
+ return {
2902
+ url: "",
2903
+ instructions: `Account deleted. Using ${label} for future requests.`,
2904
+ method: "auto",
2905
+ callback: async () => fallbackResult,
2906
+ };
2907
+ }
2908
+ }
2909
+ try {
2910
+ await client.auth.set({
2911
+ path: { id: providerId },
2912
+ body: {
2913
+ type: "oauth",
2914
+ refresh: "",
2915
+ access: "",
2916
+ expires: 0,
2917
+ },
2918
+ });
2919
+ }
2920
+ catch (storeError) {
2921
+ log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) });
2922
+ }
2923
+ return {
2924
+ url: "",
2925
+ instructions: "All accounts deleted. Run `opencode auth login` to reauthenticate.",
2926
+ method: "auto",
2927
+ callback: async () => ({
2928
+ type: "failed",
2929
+ error: "All accounts deleted. Reauthentication required.",
2930
+ }),
2931
+ };
2932
+ }
2933
+ if (menuResult.refreshAccountIndex !== undefined) {
2934
+ refreshAccountIndex = menuResult.refreshAccountIndex;
2935
+ const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email;
2936
+ console.log(`\nRe-authenticating ${refreshEmail || "account"}...\n`);
2937
+ startFresh = false;
2938
+ }
2939
+ if (menuResult.deleteAll) {
2940
+ await clearAccounts();
2941
+ console.log("\nAll accounts deleted.\n");
2942
+ startFresh = true;
2943
+ try {
2944
+ await client.auth.set({
2945
+ path: { id: providerId },
2946
+ body: {
2947
+ type: "oauth",
2948
+ refresh: "",
2949
+ access: "",
2950
+ expires: 0,
2951
+ },
2952
+ });
2953
+ }
2954
+ catch (storeError) {
2955
+ log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) });
2956
+ }
2957
+ }
2958
+ else {
2959
+ startFresh = menuResult.mode === "fresh";
2960
+ }
2961
+ if (startFresh && !menuResult.deleteAll) {
2962
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
2963
+ }
2964
+ else if (!startFresh) {
2965
+ console.log("\nAdding to existing accounts.\n");
2966
+ }
2967
+ }
2968
+ while (accounts.length < MAX_OAUTH_ACCOUNTS) {
2969
+ console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);
2970
+ const projectId = await promptProjectId();
2971
+ const result = await (async () => {
2972
+ const authorization = await authorizeAntigravity(projectId);
2973
+ const fallbackState = getStateFromAuthorizationUrl(authorization.url);
2974
+ console.log("\nOAuth URL:\n" + authorization.url + "\n");
2975
+ if (useManualMode) {
2976
+ const browserOpened = await openBrowser(authorization.url);
2977
+ if (!browserOpened) {
2978
+ console.log("Could not open browser automatically.");
2979
+ console.log("Please open the URL above manually in your local browser.\n");
2980
+ }
2981
+ return promptManualOAuthInput(fallbackState);
2982
+ }
2983
+ let listener = null;
2984
+ if (!isHeadless) {
2985
+ try {
2986
+ listener = await startOAuthListener();
2987
+ }
2988
+ catch {
2989
+ listener = null;
2990
+ }
2991
+ }
2992
+ if (!isHeadless) {
2993
+ await openBrowser(authorization.url);
2994
+ }
2995
+ if (listener) {
2996
+ try {
2997
+ const SOFT_TIMEOUT_MS = 30000;
2998
+ const callbackPromise = listener.waitForCallback();
2999
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS));
3000
+ let callbackUrl;
3001
+ try {
3002
+ callbackUrl = await Promise.race([
3003
+ callbackPromise,
3004
+ timeoutPromise,
3005
+ ]);
3006
+ }
3007
+ catch (err) {
3008
+ if (err instanceof Error &&
3009
+ err.message === "SOFT_TIMEOUT") {
3010
+ console.log("\n⏳ Automatic callback not received after 30 seconds.");
3011
+ console.log("You can paste the redirect URL manually.\n");
3012
+ console.log("OAuth URL (in case you need it again):");
3013
+ console.log(authorization.url + "\n");
3014
+ try {
3015
+ await listener.close();
3016
+ }
3017
+ catch { }
3018
+ return promptManualOAuthInput(fallbackState);
3019
+ }
3020
+ throw err;
3021
+ }
3022
+ const params = extractOAuthCallbackParams(callbackUrl);
3023
+ if (!params) {
3024
+ return {
3025
+ type: "failed",
3026
+ error: "Missing code or state in callback URL",
3027
+ };
3028
+ }
3029
+ return exchangeAntigravity(params.code, params.state);
3030
+ }
3031
+ catch (error) {
3032
+ if (error instanceof Error &&
3033
+ error.message !== "SOFT_TIMEOUT") {
3034
+ return {
3035
+ type: "failed",
3036
+ error: error.message,
3037
+ };
3038
+ }
3039
+ return {
3040
+ type: "failed",
3041
+ error: error instanceof Error
3042
+ ? error.message
3043
+ : "Unknown error",
3044
+ };
3045
+ }
3046
+ finally {
3047
+ try {
3048
+ await listener.close();
3049
+ }
3050
+ catch { }
3051
+ }
3052
+ }
3053
+ return promptManualOAuthInput(fallbackState);
3054
+ })();
3055
+ if (result.type === "failed") {
3056
+ if (accounts.length === 0) {
3057
+ return {
3058
+ url: "",
3059
+ instructions: `Authentication failed: ${result.error}`,
3060
+ method: "auto",
3061
+ callback: async () => result,
3062
+ };
3063
+ }
3064
+ console.warn(`[opencode-agy-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
3065
+ break;
3066
+ }
3067
+ accounts.push(result);
3068
+ try {
3069
+ await client.tui.showToast({
3070
+ body: {
3071
+ message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`,
3072
+ variant: "success",
3073
+ },
3074
+ });
3075
+ }
3076
+ catch { }
3077
+ try {
3078
+ if (refreshAccountIndex !== undefined) {
3079
+ const currentStorage = await loadAccounts();
3080
+ if (currentStorage) {
3081
+ const updatedAccounts = [...currentStorage.accounts];
3082
+ const parts = parseRefreshParts(result.refresh);
3083
+ if (parts.refreshToken) {
3084
+ updatedAccounts[refreshAccountIndex] = {
3085
+ email: result.email ??
3086
+ updatedAccounts[refreshAccountIndex]?.email,
3087
+ refreshToken: parts.refreshToken,
3088
+ projectId: parts.projectId ??
3089
+ updatedAccounts[refreshAccountIndex]?.projectId,
3090
+ managedProjectId: parts.managedProjectId ??
3091
+ updatedAccounts[refreshAccountIndex]
3092
+ ?.managedProjectId,
3093
+ addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ??
3094
+ Date.now(),
3095
+ lastUsed: Date.now(),
3096
+ };
3097
+ await saveAccounts({
3098
+ version: 4,
3099
+ accounts: updatedAccounts,
3100
+ activeIndex: currentStorage.activeIndex,
3101
+ activeIndexByFamily: currentStorage.activeIndexByFamily,
3102
+ });
3103
+ }
3104
+ }
3105
+ }
3106
+ else {
3107
+ const isFirstAccount = accounts.length === 1;
3108
+ await persistAccountPool([result], isFirstAccount && startFresh);
3109
+ }
3110
+ }
3111
+ catch { }
3112
+ if (refreshAccountIndex !== undefined) {
3113
+ break;
3114
+ }
3115
+ if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
3116
+ break;
3117
+ }
3118
+ // Get the actual deduplicated account count from storage for the prompt
3119
+ let currentAccountCount = accounts.length;
3120
+ try {
3121
+ const currentStorage = await loadAccounts();
3122
+ if (currentStorage) {
3123
+ currentAccountCount = currentStorage.accounts.length;
3124
+ }
3125
+ }
3126
+ catch {
3127
+ // Fall back to accounts.length if we can't read storage
3128
+ }
3129
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
3130
+ if (!addAnother) {
3131
+ break;
3132
+ }
3133
+ }
3134
+ const primary = accounts[0];
3135
+ if (!primary) {
3136
+ return {
3137
+ url: "",
3138
+ instructions: "Authentication cancelled",
3139
+ method: "auto",
3140
+ callback: async () => ({
3141
+ type: "failed",
3142
+ error: "Authentication cancelled",
3143
+ }),
3144
+ };
3145
+ }
3146
+ let actualAccountCount = accounts.length;
3147
+ try {
3148
+ const finalStorage = await loadAccounts();
3149
+ if (finalStorage) {
3150
+ actualAccountCount = finalStorage.accounts.length;
3151
+ }
3152
+ }
3153
+ catch { }
3154
+ const successMessage = refreshAccountIndex !== undefined
3155
+ ? `Token refreshed successfully.`
3156
+ : `Multi-account setup complete (${actualAccountCount} account(s)).`;
3157
+ return {
3158
+ url: "",
3159
+ instructions: successMessage,
3160
+ method: "auto",
3161
+ callback: async () => primary,
3162
+ };
3163
+ }
3164
+ // TUI flow (`/connect`) does not support per-account prompts.
3165
+ // Default to adding new accounts (non-destructive).
3166
+ // Users can run `opencode auth logout` first if they want a fresh start.
3167
+ const projectId = "";
3168
+ // Check existing accounts count for toast message
3169
+ const existingStorage = await loadAccounts();
3170
+ const existingCount = existingStorage?.accounts.length ?? 0;
3171
+ const useManualFlow = isHeadless || shouldSkipLocalServer();
3172
+ let listener = null;
3173
+ if (!useManualFlow) {
3174
+ try {
3175
+ listener = await startOAuthListener();
3176
+ }
3177
+ catch {
3178
+ listener = null;
3179
+ }
3180
+ }
3181
+ const authorization = await authorizeAntigravity(projectId);
3182
+ const fallbackState = getStateFromAuthorizationUrl(authorization.url);
3183
+ if (!useManualFlow) {
3184
+ const browserOpened = await openBrowser(authorization.url);
3185
+ if (!browserOpened) {
3186
+ listener?.close().catch(() => { });
3187
+ listener = null;
3188
+ }
3189
+ }
3190
+ if (listener) {
3191
+ return {
3192
+ url: authorization.url,
3193
+ instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
3194
+ method: "auto",
3195
+ callback: async () => {
3196
+ const CALLBACK_TIMEOUT_MS = 30000;
3197
+ try {
3198
+ const callbackPromise = listener.waitForCallback();
3199
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS));
3200
+ let callbackUrl;
3201
+ try {
3202
+ callbackUrl = await Promise.race([
3203
+ callbackPromise,
3204
+ timeoutPromise,
3205
+ ]);
3206
+ }
3207
+ catch (err) {
3208
+ if (err instanceof Error &&
3209
+ err.message === "CALLBACK_TIMEOUT") {
3210
+ return {
3211
+ type: "failed",
3212
+ error: "Callback timeout - please use CLI with --no-browser flag for manual input",
3213
+ };
3214
+ }
3215
+ throw err;
3216
+ }
3217
+ const params = extractOAuthCallbackParams(callbackUrl);
3218
+ if (!params) {
3219
+ return {
3220
+ type: "failed",
3221
+ error: "Missing code or state in callback URL",
3222
+ };
3223
+ }
3224
+ const result = await exchangeAntigravity(params.code, params.state);
3225
+ if (result.type === "success") {
3226
+ try {
3227
+ await persistAccountPool([result], false);
3228
+ }
3229
+ catch { }
3230
+ const newTotal = existingCount + 1;
3231
+ const toastMessage = existingCount > 0
3232
+ ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
3233
+ : `Authenticated${result.email ? ` (${result.email})` : ""}`;
3234
+ try {
3235
+ await client.tui.showToast({
3236
+ body: {
3237
+ message: toastMessage,
3238
+ variant: "success",
3239
+ },
3240
+ });
3241
+ }
3242
+ catch { }
3243
+ }
3244
+ return result;
3245
+ }
3246
+ catch (error) {
3247
+ return {
3248
+ type: "failed",
3249
+ error: error instanceof Error
3250
+ ? error.message
3251
+ : "Unknown error",
3252
+ };
3253
+ }
3254
+ finally {
3255
+ try {
3256
+ await listener.close();
3257
+ }
3258
+ catch { }
3259
+ }
3260
+ },
3261
+ };
3262
+ }
3263
+ return {
3264
+ url: authorization.url,
3265
+ instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
3266
+ method: "code",
3267
+ callback: async (codeInput) => {
3268
+ const params = parseOAuthCallbackInput(codeInput, fallbackState);
3269
+ if ("error" in params) {
3270
+ return { type: "failed", error: params.error };
3271
+ }
3272
+ const result = await exchangeAntigravity(params.code, params.state);
3273
+ if (result.type === "success") {
3274
+ try {
3275
+ // TUI flow adds to existing accounts (non-destructive)
3276
+ await persistAccountPool([result], false);
3277
+ }
3278
+ catch {
3279
+ // ignore
3280
+ }
3281
+ // Show appropriate toast message
3282
+ const newTotal = existingCount + 1;
3283
+ const toastMessage = existingCount > 0
3284
+ ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
3285
+ : `Authenticated${result.email ? ` (${result.email})` : ""}`;
3286
+ try {
3287
+ await client.tui.showToast({
3288
+ body: {
3289
+ message: toastMessage,
3290
+ variant: "success",
3291
+ },
3292
+ });
3293
+ }
3294
+ catch {
3295
+ // TUI may not be available
3296
+ }
3297
+ }
3298
+ return result;
3299
+ },
3300
+ };
3301
+ },
3302
+ },
3303
+ {
3304
+ label: "Manually enter API Key",
3305
+ type: "api",
3306
+ },
3307
+ ],
3308
+ },
3309
+ };
3310
+ };
3311
+ export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);
3312
+ export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;
3313
+ function toUrlString(value) {
3314
+ if (typeof value === "string") {
3315
+ return value;
3316
+ }
3317
+ const candidate = value.url;
3318
+ if (candidate) {
3319
+ return candidate;
3320
+ }
3321
+ return value.toString();
3322
+ }
3323
+ function toWarmupStreamUrl(value) {
3324
+ const urlString = toUrlString(value);
3325
+ try {
3326
+ const url = new URL(urlString);
3327
+ if (!url.pathname.includes(":streamGenerateContent")) {
3328
+ url.pathname = url.pathname.replace(":generateContent", ":streamGenerateContent");
3329
+ }
3330
+ url.searchParams.set("alt", "sse");
3331
+ return url.toString();
3332
+ }
3333
+ catch {
3334
+ return urlString;
3335
+ }
3336
+ }
3337
+ function extractModelFromUrl(urlString) {
3338
+ const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/);
3339
+ return match?.[1] ?? null;
3340
+ }
3341
+ function extractModelFromUrlWithSuffix(urlString) {
3342
+ const match = urlString.match(/\/models\/([^:\/\?]+)/);
3343
+ return match?.[1] ?? null;
3344
+ }
3345
+ function getModelFamilyFromUrl(urlString) {
3346
+ const model = extractModelFromUrl(urlString);
3347
+ let family = "gemini";
3348
+ if (model && model.includes("claude")) {
3349
+ family = "claude";
3350
+ }
3351
+ if (isDebugEnabled()) {
3352
+ logModelFamily(urlString, model, family);
3353
+ }
3354
+ return family;
3355
+ }
3356
+ function resolveQuotaFallbackHeaderStyle(input) {
3357
+ if (input.family !== "gemini") {
3358
+ return null;
3359
+ }
3360
+ if (!input.alternateStyle || input.alternateStyle === input.headerStyle) {
3361
+ return null;
3362
+ }
3363
+ return input.alternateStyle;
3364
+ }
3365
+ function resolveHeaderRoutingDecision(urlString, family, config) {
3366
+ const cliFirst = getCliFirst(config);
3367
+ const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst);
3368
+ const explicitQuota = isExplicitQuotaFromUrl(urlString);
3369
+ return {
3370
+ cliFirst,
3371
+ preferredHeaderStyle,
3372
+ explicitQuota,
3373
+ allowQuotaFallback: family === "gemini",
3374
+ };
3375
+ }
3376
+ function getCliFirst(config) {
3377
+ return (config.cli_first ?? false);
3378
+ }
3379
+ function getHeaderStyleFromUrl(urlString, family, cliFirst = false) {
3380
+ if (family === "claude") {
3381
+ return "antigravity";
3382
+ }
3383
+ const modelWithSuffix = extractModelFromUrlWithSuffix(urlString);
3384
+ if (!modelWithSuffix) {
3385
+ return cliFirst ? "gemini-cli" : "antigravity";
3386
+ }
3387
+ const { quotaPreference } = resolveModelWithTier(modelWithSuffix, {
3388
+ cli_first: cliFirst,
3389
+ });
3390
+ return quotaPreference ?? "antigravity";
3391
+ }
3392
+ function isExplicitQuotaFromUrl(urlString) {
3393
+ const modelWithSuffix = extractModelFromUrlWithSuffix(urlString);
3394
+ if (!modelWithSuffix) {
3395
+ return false;
3396
+ }
3397
+ const { explicitQuota } = resolveModelWithTier(modelWithSuffix);
3398
+ return explicitQuota ?? false;
3399
+ }
3400
+ export const __testExports = {
3401
+ getHeaderStyleFromUrl,
3402
+ resolveHeaderRoutingDecision,
3403
+ resolveQuotaFallbackHeaderStyle,
3404
+ runAutoCompactCommand,
3405
+ createContextOverflowSyntheticResponse,
3406
+ };
3407
+ //# sourceMappingURL=plugin.js.map