opencode-antigravity-auth 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +212 -98
  2. package/dist/src/constants.d.ts +6 -0
  3. package/dist/src/constants.d.ts.map +1 -1
  4. package/dist/src/constants.js +5 -0
  5. package/dist/src/constants.js.map +1 -1
  6. package/dist/src/hooks/auto-update-checker/cache.d.ts +3 -0
  7. package/dist/src/hooks/auto-update-checker/cache.d.ts.map +1 -0
  8. package/dist/src/hooks/auto-update-checker/cache.js +71 -0
  9. package/dist/src/hooks/auto-update-checker/cache.js.map +1 -0
  10. package/dist/src/hooks/auto-update-checker/checker.d.ts +16 -0
  11. package/dist/src/hooks/auto-update-checker/checker.d.ts.map +1 -0
  12. package/dist/src/hooks/auto-update-checker/checker.js +237 -0
  13. package/dist/src/hooks/auto-update-checker/checker.js.map +1 -0
  14. package/dist/src/hooks/auto-update-checker/constants.d.ts +9 -0
  15. package/dist/src/hooks/auto-update-checker/constants.d.ts.map +1 -0
  16. package/dist/src/hooks/auto-update-checker/constants.js +23 -0
  17. package/dist/src/hooks/auto-update-checker/constants.js.map +1 -0
  18. package/dist/src/hooks/auto-update-checker/index.d.ts +34 -0
  19. package/dist/src/hooks/auto-update-checker/index.d.ts.map +1 -0
  20. package/dist/src/hooks/auto-update-checker/index.js +121 -0
  21. package/dist/src/hooks/auto-update-checker/index.js.map +1 -0
  22. package/dist/src/hooks/auto-update-checker/types.d.ts +25 -0
  23. package/dist/src/hooks/auto-update-checker/types.d.ts.map +1 -0
  24. package/dist/src/hooks/auto-update-checker/types.js +1 -0
  25. package/dist/src/hooks/auto-update-checker/types.js.map +1 -0
  26. package/dist/src/plugin/accounts.d.ts +25 -11
  27. package/dist/src/plugin/accounts.d.ts.map +1 -1
  28. package/dist/src/plugin/accounts.js +161 -55
  29. package/dist/src/plugin/accounts.js.map +1 -1
  30. package/dist/src/plugin/debug.d.ts +32 -0
  31. package/dist/src/plugin/debug.d.ts.map +1 -1
  32. package/dist/src/plugin/debug.js +140 -12
  33. package/dist/src/plugin/debug.js.map +1 -1
  34. package/dist/src/plugin/request.d.ts +6 -2
  35. package/dist/src/plugin/request.d.ts.map +1 -1
  36. package/dist/src/plugin/request.js +361 -21
  37. package/dist/src/plugin/request.js.map +1 -1
  38. package/dist/src/plugin/storage.d.ts +52 -9
  39. package/dist/src/plugin/storage.d.ts.map +1 -1
  40. package/dist/src/plugin/storage.js +91 -10
  41. package/dist/src/plugin/storage.js.map +1 -1
  42. package/dist/src/plugin/types.d.ts +8 -0
  43. package/dist/src/plugin/types.d.ts.map +1 -1
  44. package/dist/src/plugin.d.ts +3 -3
  45. package/dist/src/plugin.d.ts.map +1 -1
  46. package/dist/src/plugin.js +865 -486
  47. package/dist/src/plugin.js.map +1 -1
  48. package/package.json +1 -1
@@ -4,13 +4,31 @@ import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
4
4
  import { accessTokenExpired, isOAuthAuth, parseRefreshParts } from "./plugin/auth";
5
5
  import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli";
6
6
  import { ensureProjectContext } from "./plugin/project";
7
- import { startAntigravityDebugRequest } from "./plugin/debug";
8
- import { isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request";
7
+ import { startAntigravityDebugRequest, logAntigravityDebugResponse, logAccountContext, logRateLimitEvent, logRateLimitSnapshot, logResponseBody, logModelFamily, isDebugEnabled, getLogFilePath, } from "./plugin/debug";
8
+ import { buildThinkingWarmupBody, isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request";
9
9
  import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token";
10
10
  import { startOAuthListener } from "./plugin/server";
11
11
  import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage";
12
12
  import { AccountManager } from "./plugin/accounts";
13
+ import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
13
14
  const MAX_OAUTH_ACCOUNTS = 10;
15
+ const MAX_WARMUP_SESSIONS = 1000;
16
+ const warmupAttemptedSessionIds = new Set();
17
+ function trackWarmupSession(sessionId) {
18
+ if (warmupAttemptedSessionIds.has(sessionId)) {
19
+ return false;
20
+ }
21
+ if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) {
22
+ const first = warmupAttemptedSessionIds.values().next().value;
23
+ if (first)
24
+ warmupAttemptedSessionIds.delete(first);
25
+ }
26
+ warmupAttemptedSessionIds.add(sessionId);
27
+ return true;
28
+ }
29
+ function untrackWarmupSession(sessionId) {
30
+ warmupAttemptedSessionIds.delete(sessionId);
31
+ }
14
32
  async function openBrowser(url) {
15
33
  try {
16
34
  if (process.platform === "darwin") {
@@ -115,8 +133,6 @@ async function persistAccountPool(results, replaceAll = false) {
115
133
  managedProjectId: parts.managedProjectId,
116
134
  addedAt: now,
117
135
  lastUsed: now,
118
- isRateLimited: false,
119
- rateLimitResetTime: 0,
120
136
  });
121
137
  continue;
122
138
  }
@@ -140,9 +156,13 @@ async function persistAccountPool(results, replaceAll = false) {
140
156
  ? 0
141
157
  : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0);
142
158
  await saveAccounts({
143
- version: 1,
159
+ version: 3,
144
160
  accounts,
145
161
  activeIndex: clampInt(activeIndex, 0, accounts.length - 1),
162
+ activeIndexByFamily: {
163
+ claude: clampInt(activeIndex, 0, accounts.length - 1),
164
+ gemini: clampInt(activeIndex, 0, accounts.length - 1),
165
+ },
146
166
  });
147
167
  }
148
168
  function retryAfterMsFromResponse(response) {
@@ -162,6 +182,150 @@ function retryAfterMsFromResponse(response) {
162
182
  }
163
183
  return 60_000;
164
184
  }
185
+ function parseDurationToMs(duration) {
186
+ const match = duration.match(/^(\d+(?:\.\d+)?)(s|m|h)?$/i);
187
+ if (!match)
188
+ return null;
189
+ const value = parseFloat(match[1]);
190
+ const unit = (match[2] || "s").toLowerCase();
191
+ switch (unit) {
192
+ case "h": return value * 3600 * 1000;
193
+ case "m": return value * 60 * 1000;
194
+ case "s": return value * 1000;
195
+ default: return value * 1000;
196
+ }
197
+ }
198
+ function extractRateLimitBodyInfo(body) {
199
+ if (!body || typeof body !== "object") {
200
+ return { retryDelayMs: null };
201
+ }
202
+ const error = body.error;
203
+ const message = error && typeof error === "object"
204
+ ? error.message
205
+ : undefined;
206
+ const details = error && typeof error === "object"
207
+ ? error.details
208
+ : undefined;
209
+ let reason;
210
+ if (Array.isArray(details)) {
211
+ for (const detail of details) {
212
+ if (!detail || typeof detail !== "object")
213
+ continue;
214
+ const type = detail["@type"];
215
+ if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) {
216
+ const detailReason = detail.reason;
217
+ if (typeof detailReason === "string") {
218
+ reason = detailReason;
219
+ break;
220
+ }
221
+ }
222
+ }
223
+ for (const detail of details) {
224
+ if (!detail || typeof detail !== "object")
225
+ continue;
226
+ const type = detail["@type"];
227
+ if (typeof type === "string" && type.includes("google.rpc.RetryInfo")) {
228
+ const retryDelay = detail.retryDelay;
229
+ if (typeof retryDelay === "string") {
230
+ const retryDelayMs = parseDurationToMs(retryDelay);
231
+ if (retryDelayMs !== null) {
232
+ return { retryDelayMs, message, reason };
233
+ }
234
+ }
235
+ }
236
+ }
237
+ for (const detail of details) {
238
+ if (!detail || typeof detail !== "object")
239
+ continue;
240
+ const metadata = detail.metadata;
241
+ if (metadata && typeof metadata === "object") {
242
+ const quotaResetDelay = metadata.quotaResetDelay;
243
+ const quotaResetTime = metadata.quotaResetTimeStamp;
244
+ if (typeof quotaResetDelay === "string") {
245
+ const quotaResetDelayMs = parseDurationToMs(quotaResetDelay);
246
+ if (quotaResetDelayMs !== null) {
247
+ return { retryDelayMs: quotaResetDelayMs, message, quotaResetTime, reason };
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ if (message) {
254
+ const afterMatch = message.match(/reset after\s+([0-9hms.]+)/i);
255
+ const rawDuration = afterMatch?.[1];
256
+ if (rawDuration) {
257
+ const parsed = parseDurationToMs(rawDuration);
258
+ if (parsed !== null) {
259
+ return { retryDelayMs: parsed, message, reason };
260
+ }
261
+ }
262
+ }
263
+ return { retryDelayMs: null, message, reason };
264
+ }
265
+ async function extractRetryInfoFromBody(response) {
266
+ try {
267
+ const text = await response.clone().text();
268
+ try {
269
+ const parsed = JSON.parse(text);
270
+ return extractRateLimitBodyInfo(parsed);
271
+ }
272
+ catch {
273
+ return { retryDelayMs: null };
274
+ }
275
+ }
276
+ catch {
277
+ return { retryDelayMs: null };
278
+ }
279
+ }
280
+ function formatWaitTime(ms) {
281
+ if (ms < 1000)
282
+ return `${ms}ms`;
283
+ const seconds = Math.ceil(ms / 1000);
284
+ if (seconds < 60)
285
+ return `${seconds}s`;
286
+ const minutes = Math.floor(seconds / 60);
287
+ const remainingSeconds = seconds % 60;
288
+ if (minutes < 60) {
289
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
290
+ }
291
+ const hours = Math.floor(minutes / 60);
292
+ const remainingMinutes = minutes % 60;
293
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
294
+ }
295
+ const SHORT_RETRY_THRESHOLD_MS = 5000;
296
+ const rateLimitStateByAccount = new Map();
297
+ function getRateLimitBackoff(accountIndex, serverRetryAfterMs) {
298
+ const now = Date.now();
299
+ const previous = rateLimitStateByAccount.get(accountIndex);
300
+ const attempt = previous && (now - previous.lastAt < 120_000) ? previous.consecutive429 + 1 : 1;
301
+ rateLimitStateByAccount.set(accountIndex, { consecutive429: attempt, lastAt: now });
302
+ const baseDelay = serverRetryAfterMs ?? 1000;
303
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60_000);
304
+ return { attempt, delayMs: Math.max(baseDelay, backoffDelay) };
305
+ }
306
+ function resetRateLimitState(accountIndex) {
307
+ rateLimitStateByAccount.delete(accountIndex);
308
+ }
309
+ // Track consecutive non-429 failures per account to prevent infinite loops
310
+ const accountFailureState = new Map();
311
+ const MAX_CONSECUTIVE_FAILURES = 5;
312
+ const FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures
313
+ const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures
314
+ function trackAccountFailure(accountIndex) {
315
+ const now = Date.now();
316
+ const previous = accountFailureState.get(accountIndex);
317
+ // Reset if last failure was more than 2 minutes ago
318
+ const failures = previous && (now - previous.lastFailureAt < FAILURE_STATE_RESET_MS)
319
+ ? previous.consecutiveFailures + 1
320
+ : 1;
321
+ accountFailureState.set(accountIndex, { consecutiveFailures: failures, lastFailureAt: now });
322
+ const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES;
323
+ const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0;
324
+ return { failures, shouldCooldown, cooldownMs };
325
+ }
326
+ function resetAccountFailureState(accountIndex) {
327
+ accountFailureState.delete(accountIndex);
328
+ }
165
329
  /**
166
330
  * Sleep for a given number of milliseconds, respecting an abort signal.
167
331
  */
@@ -189,371 +353,699 @@ function sleep(ms, signal) {
189
353
  /**
190
354
  * Creates an Antigravity OAuth plugin for a specific provider ID.
191
355
  */
192
- export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
193
- auth: {
194
- provider: providerId,
195
- loader: async (getAuth, provider) => {
196
- const auth = await getAuth();
197
- // If OpenCode has no valid OAuth auth, clear any stale account storage
198
- if (!isOAuthAuth(auth)) {
199
- try {
200
- await clearAccounts();
201
- }
202
- catch {
203
- // ignore
204
- }
205
- return {};
206
- }
207
- // Validate that stored accounts are in sync with OpenCode's auth
208
- // If OpenCode's refresh token doesn't match any stored account, clear stale storage
209
- const authParts = parseRefreshParts(auth.refresh);
210
- const storedAccounts = await loadAccounts();
211
- if (storedAccounts && storedAccounts.accounts.length > 0 && authParts.refreshToken) {
212
- const hasMatchingAccount = storedAccounts.accounts.some((acc) => acc.refreshToken === authParts.refreshToken);
213
- if (!hasMatchingAccount) {
214
- // OpenCode's auth doesn't match any stored account - storage is stale
215
- // Clear it and let the user re-authenticate
216
- console.warn("[opencode-antigravity-auth] Stored accounts don't match OpenCode's auth. Clearing stale storage.");
356
+ export const createAntigravityPlugin = (providerId) => async ({ client, directory }) => {
357
+ const updateChecker = createAutoUpdateCheckerHook(client, directory, {
358
+ showStartupToast: true,
359
+ autoUpdate: true,
360
+ });
361
+ return {
362
+ event: updateChecker.event,
363
+ auth: {
364
+ provider: providerId,
365
+ loader: async (getAuth, provider) => {
366
+ const auth = await getAuth();
367
+ // If OpenCode has no valid OAuth auth, clear any stale account storage
368
+ if (!isOAuthAuth(auth)) {
217
369
  try {
218
370
  await clearAccounts();
219
371
  }
220
372
  catch {
221
373
  // ignore
222
374
  }
375
+ return {};
223
376
  }
224
- }
225
- const accountManager = await AccountManager.loadFromDisk(auth);
226
- if (accountManager.getAccountCount() > 0) {
227
- try {
228
- await accountManager.saveToDisk();
229
- }
230
- catch (error) {
231
- console.error("[opencode-antigravity-auth] Failed to persist initial account pool:", error);
232
- }
233
- }
234
- if (provider.models) {
235
- for (const model of Object.values(provider.models)) {
236
- if (model) {
237
- model.cost = { input: 0, output: 0 };
377
+ // Validate that stored accounts are in sync with OpenCode's auth
378
+ // If OpenCode's refresh token doesn't match any stored account, clear stale storage
379
+ const authParts = parseRefreshParts(auth.refresh);
380
+ const storedAccounts = await loadAccounts();
381
+ if (storedAccounts && storedAccounts.accounts.length > 0 && authParts.refreshToken) {
382
+ const hasMatchingAccount = storedAccounts.accounts.some((acc) => acc.refreshToken === authParts.refreshToken);
383
+ if (!hasMatchingAccount) {
384
+ // OpenCode's auth doesn't match any stored account - storage is stale
385
+ // Clear it and let the user re-authenticate
386
+ console.warn("[opencode-antigravity-auth] Stored accounts don't match OpenCode's auth. Clearing stale storage.");
387
+ try {
388
+ await clearAccounts();
389
+ }
390
+ catch {
391
+ // ignore
392
+ }
238
393
  }
239
394
  }
240
- }
241
- return {
242
- apiKey: "",
243
- async fetch(input, init) {
244
- // If the request is for the *other* provider, we might still want to intercept if URL matches
245
- // But strict compliance means we only handle requests if the auth provider matches.
246
- // Since loader is instantiated per provider, we are good.
247
- if (!isGenerativeLanguageRequest(input)) {
248
- return fetch(input, init);
249
- }
250
- const latestAuth = await getAuth();
251
- if (!isOAuthAuth(latestAuth)) {
252
- return fetch(input, init);
395
+ const accountManager = await AccountManager.loadFromDisk(auth);
396
+ if (accountManager.getAccountCount() > 0) {
397
+ try {
398
+ await accountManager.saveToDisk();
253
399
  }
254
- if (accountManager.getAccountCount() === 0) {
255
- throw new Error("No Antigravity accounts configured. Run `opencode auth login`.");
400
+ catch (error) {
401
+ console.error("[opencode-antigravity-auth] Failed to persist initial account pool:", error);
256
402
  }
257
- let lastFailure = null;
258
- let lastError = null;
259
- const abortSignal = init?.signal ?? undefined;
260
- // Track which account was used in this request for detecting switches
261
- // This is scoped to the fetch call so it resets per-request
262
- let previousAccountIndex = null;
263
- // Helper to check if request was aborted
264
- const checkAborted = () => {
265
- if (abortSignal?.aborted) {
266
- throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted");
267
- }
268
- };
269
- // Helper to show toast without blocking on abort
270
- const showToast = async (message, variant) => {
271
- if (abortSignal?.aborted)
272
- return;
403
+ }
404
+ if (isDebugEnabled()) {
405
+ const logPath = getLogFilePath();
406
+ if (logPath) {
273
407
  try {
274
408
  await client.tui.showToast({
275
- body: { message, variant },
409
+ body: { message: `Debug log: ${logPath}`, variant: "info" },
276
410
  });
277
411
  }
278
412
  catch {
279
413
  // TUI may not be available
280
414
  }
281
- };
282
- // Use while(true) loop to handle rate limits with backoff
283
- // This ensures we wait and retry when all accounts are rate-limited
284
- while (true) {
285
- // Check for abort at the start of each iteration
286
- checkAborted();
287
- const accountCount = accountManager.getAccountCount();
288
- if (accountCount === 0) {
289
- throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
290
- }
291
- const account = accountManager.pickNext();
292
- if (!account) {
293
- // All accounts are rate-limited - wait and retry
294
- const waitMs = accountManager.getMinWaitTimeMs() || 60_000;
295
- const waitSec = Math.max(1, Math.ceil(waitMs / 1000));
296
- await showToast(`All ${accountCount} account(s) rate-limited. Waiting ${waitSec}s...`, "warning");
297
- // Wait for the cooldown to expire
298
- await sleep(waitMs, abortSignal);
299
- continue;
415
+ }
416
+ }
417
+ if (provider.models) {
418
+ for (const model of Object.values(provider.models)) {
419
+ if (model) {
420
+ model.cost = { input: 0, output: 0 };
300
421
  }
301
- // Show toast when switching to a different account
302
- const isAccountSwitch = previousAccountIndex !== null && previousAccountIndex !== account.index;
303
- if ((isAccountSwitch || previousAccountIndex === null) && accountCount > 1) {
304
- const accountLabel = account.email || `Account ${account.index + 1}`;
305
- await showToast(`Using ${accountLabel}${accountCount > 1 ? ` (${account.index + 1}/${accountCount})` : ""}`, "info");
422
+ }
423
+ }
424
+ return {
425
+ apiKey: "",
426
+ async fetch(input, init) {
427
+ // If the request is for the *other* provider, we might still want to intercept if URL matches
428
+ // But strict compliance means we only handle requests if the auth provider matches.
429
+ // Since loader is instantiated per provider, we are good.
430
+ if (!isGenerativeLanguageRequest(input)) {
431
+ return fetch(input, init);
306
432
  }
307
- previousAccountIndex = account.index;
308
- try {
309
- await accountManager.saveToDisk();
433
+ const latestAuth = await getAuth();
434
+ if (!isOAuthAuth(latestAuth)) {
435
+ return fetch(input, init);
310
436
  }
311
- catch (error) {
312
- console.error("[opencode-antigravity-auth] Failed to persist rotation state:", error);
437
+ if (accountManager.getAccountCount() === 0) {
438
+ throw new Error("No Antigravity accounts configured. Run `opencode auth login`.");
313
439
  }
314
- let authRecord = accountManager.toAuthDetails(account);
315
- if (accessTokenExpired(authRecord)) {
440
+ const urlString = toUrlString(input);
441
+ const family = getModelFamilyFromUrl(urlString);
442
+ const debugLines = [];
443
+ const pushDebug = (line) => {
444
+ if (!isDebugEnabled())
445
+ return;
446
+ debugLines.push(line);
447
+ };
448
+ pushDebug(`request=${urlString}`);
449
+ let lastFailure = null;
450
+ let lastError = null;
451
+ const abortSignal = init?.signal ?? undefined;
452
+ // Helper to check if request was aborted
453
+ const checkAborted = () => {
454
+ if (abortSignal?.aborted) {
455
+ throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted");
456
+ }
457
+ };
458
+ // Helper to show toast without blocking on abort
459
+ const showToast = async (message, variant) => {
460
+ if (abortSignal?.aborted)
461
+ return;
316
462
  try {
317
- const refreshed = await refreshAccessToken(authRecord, client, providerId);
318
- if (!refreshed) {
319
- lastError = new Error("Antigravity token refresh failed");
320
- continue;
321
- }
322
- accountManager.updateFromAuth(account, refreshed);
323
- authRecord = refreshed;
324
- try {
325
- await accountManager.saveToDisk();
326
- }
327
- catch (error) {
328
- console.error("[opencode-antigravity-auth] Failed to persist refreshed auth:", error);
463
+ await client.tui.showToast({
464
+ body: { message, variant },
465
+ });
466
+ }
467
+ catch {
468
+ // TUI may not be available
469
+ }
470
+ };
471
+ // Use while(true) loop to handle rate limits with backoff
472
+ // This ensures we wait and retry when all accounts are rate-limited
473
+ const quietMode = process.env.OPENCODE_ANTIGRAVITY_QUIET === "1";
474
+ while (true) {
475
+ // Check for abort at the start of each iteration
476
+ checkAborted();
477
+ const accountCount = accountManager.getAccountCount();
478
+ if (accountCount === 0) {
479
+ throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
480
+ }
481
+ const account = accountManager.getCurrentOrNextForFamily(family);
482
+ if (!account) {
483
+ // All accounts are rate-limited - wait and retry
484
+ const waitMs = accountManager.getMinWaitTimeForFamily(family) || 60_000;
485
+ const waitSec = Math.max(1, Math.ceil(waitMs / 1000));
486
+ pushDebug(`all-rate-limited family=${family} accounts=${accountCount}`);
487
+ if (isDebugEnabled()) {
488
+ logAccountContext("All accounts rate-limited", {
489
+ index: -1,
490
+ family,
491
+ totalAccounts: accountCount,
492
+ });
493
+ logRateLimitSnapshot(family, accountManager.getAccountsSnapshot());
329
494
  }
495
+ await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSec}s...`, "warning");
496
+ // Wait for the cooldown to expire
497
+ await sleep(waitMs, abortSignal);
498
+ continue;
499
+ }
500
+ pushDebug(`selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount}`);
501
+ if (isDebugEnabled()) {
502
+ logAccountContext("Selected", {
503
+ index: account.index,
504
+ email: account.email,
505
+ family,
506
+ totalAccounts: accountCount,
507
+ rateLimitState: account.rateLimitResetTimes,
508
+ });
509
+ }
510
+ // Show toast when switching to a different account (debounced, respects quiet mode)
511
+ if (!quietMode && accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) {
512
+ const accountLabel = account.email || `Account ${account.index + 1}`;
513
+ await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
514
+ accountManager.markToastShown(account.index);
515
+ }
516
+ try {
517
+ await accountManager.saveToDisk();
330
518
  }
331
519
  catch (error) {
332
- if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") {
333
- const removed = accountManager.removeAccount(account);
334
- if (removed) {
335
- console.warn("[opencode-antigravity-auth] Removed revoked account from pool. Reauthenticate it via `opencode auth login` to add it back.");
336
- try {
337
- await accountManager.saveToDisk();
338
- }
339
- catch (persistError) {
340
- console.error("[opencode-antigravity-auth] Failed to persist revoked account removal:", persistError);
520
+ console.error("[opencode-antigravity-auth] Failed to persist rotation state:", error);
521
+ }
522
+ let authRecord = accountManager.toAuthDetails(account);
523
+ if (accessTokenExpired(authRecord)) {
524
+ try {
525
+ const refreshed = await refreshAccessToken(authRecord, client, providerId);
526
+ if (!refreshed) {
527
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
528
+ lastError = new Error("Antigravity token refresh failed");
529
+ if (shouldCooldown) {
530
+ accountManager.markRateLimited(account, cooldownMs, family, "antigravity");
531
+ pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`);
341
532
  }
533
+ continue;
342
534
  }
343
- if (accountManager.getAccountCount() === 0) {
344
- try {
345
- await client.auth.set({
346
- path: { id: providerId },
347
- body: { type: "oauth", refresh: "", access: "", expires: 0 },
348
- });
535
+ resetAccountFailureState(account.index);
536
+ accountManager.updateFromAuth(account, refreshed);
537
+ authRecord = refreshed;
538
+ try {
539
+ await accountManager.saveToDisk();
540
+ }
541
+ catch (error) {
542
+ console.error("[opencode-antigravity-auth] Failed to persist refreshed auth:", error);
543
+ }
544
+ }
545
+ catch (error) {
546
+ if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") {
547
+ const removed = accountManager.removeAccount(account);
548
+ if (removed) {
549
+ console.warn("[opencode-antigravity-auth] Removed revoked account from pool. Reauthenticate it via `opencode auth login` to add it back.");
550
+ try {
551
+ await accountManager.saveToDisk();
552
+ }
553
+ catch (persistError) {
554
+ console.error("[opencode-antigravity-auth] Failed to persist revoked account removal:", persistError);
555
+ }
349
556
  }
350
- catch (storeError) {
351
- console.error("Failed to clear stored Antigravity OAuth credentials:", storeError);
557
+ if (accountManager.getAccountCount() === 0) {
558
+ try {
559
+ await client.auth.set({
560
+ path: { id: providerId },
561
+ body: { type: "oauth", refresh: "", access: "", expires: 0 },
562
+ });
563
+ }
564
+ catch (storeError) {
565
+ console.error("Failed to clear stored Antigravity OAuth credentials:", storeError);
566
+ }
567
+ throw new Error("All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.");
352
568
  }
353
- throw new Error("All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.");
569
+ lastError = error;
570
+ continue;
571
+ }
572
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
573
+ lastError = error instanceof Error ? error : new Error(String(error));
574
+ if (shouldCooldown) {
575
+ accountManager.markRateLimited(account, cooldownMs, family, "antigravity");
576
+ pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`);
354
577
  }
355
- lastError = error;
356
578
  continue;
357
579
  }
358
- lastError = error instanceof Error ? error : new Error(String(error));
580
+ }
581
+ const accessToken = authRecord.access;
582
+ if (!accessToken) {
583
+ lastError = new Error("Missing access token");
359
584
  continue;
360
585
  }
361
- }
362
- const accessToken = authRecord.access;
363
- if (!accessToken) {
364
- lastError = new Error("Missing access token");
365
- continue;
366
- }
367
- let projectContext;
368
- try {
369
- projectContext = await ensureProjectContext(authRecord);
370
- }
371
- catch (error) {
372
- lastError = error instanceof Error ? error : new Error(String(error));
373
- continue;
374
- }
375
- if (projectContext.auth !== authRecord) {
376
- accountManager.updateFromAuth(account, projectContext.auth);
377
- authRecord = projectContext.auth;
586
+ let projectContext;
378
587
  try {
379
- await accountManager.saveToDisk();
588
+ projectContext = await ensureProjectContext(authRecord);
589
+ resetAccountFailureState(account.index);
380
590
  }
381
591
  catch (error) {
382
- console.error("[opencode-antigravity-auth] Failed to persist project context:", error);
592
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
593
+ lastError = error instanceof Error ? error : new Error(String(error));
594
+ if (shouldCooldown) {
595
+ accountManager.markRateLimited(account, cooldownMs, family, "antigravity");
596
+ pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`);
597
+ }
598
+ continue;
383
599
  }
384
- }
385
- // Try endpoint fallbacks
386
- let shouldSwitchAccount = false;
387
- for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
388
- const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
389
- try {
390
- const prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint);
391
- const originalUrl = toUrlString(input);
392
- const resolvedUrl = toUrlString(prepared.request);
393
- const debugContext = startAntigravityDebugRequest({
394
- originalUrl,
395
- resolvedUrl,
396
- method: prepared.init.method,
397
- headers: prepared.init.headers,
398
- body: prepared.init.body,
399
- streaming: prepared.streaming,
400
- projectId: projectContext.effectiveProjectId,
600
+ if (projectContext.auth !== authRecord) {
601
+ accountManager.updateFromAuth(account, projectContext.auth);
602
+ authRecord = projectContext.auth;
603
+ try {
604
+ await accountManager.saveToDisk();
605
+ }
606
+ catch (error) {
607
+ console.error("[opencode-antigravity-auth] Failed to persist project context:", error);
608
+ }
609
+ }
610
+ const runThinkingWarmup = async (prepared, projectId) => {
611
+ if (!prepared.needsSignedThinkingWarmup || !prepared.sessionId) {
612
+ return;
613
+ }
614
+ if (!trackWarmupSession(prepared.sessionId)) {
615
+ return;
616
+ }
617
+ const warmupBody = buildThinkingWarmupBody(typeof prepared.init.body === "string" ? prepared.init.body : undefined, Boolean(prepared.effectiveModel?.toLowerCase().includes("claude") && prepared.effectiveModel?.toLowerCase().includes("thinking")));
618
+ if (!warmupBody) {
619
+ return;
620
+ }
621
+ const warmupUrl = toWarmupStreamUrl(prepared.request);
622
+ const warmupHeaders = new Headers(prepared.init.headers ?? {});
623
+ warmupHeaders.set("accept", "text/event-stream");
624
+ const warmupInit = {
625
+ ...prepared.init,
626
+ method: prepared.init.method ?? "POST",
627
+ headers: warmupHeaders,
628
+ body: warmupBody,
629
+ };
630
+ const warmupDebugContext = startAntigravityDebugRequest({
631
+ originalUrl: warmupUrl,
632
+ resolvedUrl: warmupUrl,
633
+ method: warmupInit.method,
634
+ headers: warmupHeaders,
635
+ body: warmupBody,
636
+ streaming: true,
637
+ projectId,
401
638
  });
402
- const response = await fetch(prepared.request, prepared.init);
403
- // Handle 429 rate limit
404
- if (response.status === 429) {
405
- const retryAfterMs = retryAfterMsFromResponse(response);
406
- accountManager.markRateLimited(account, retryAfterMs);
639
+ try {
640
+ pushDebug("thinking-warmup: start");
641
+ const warmupResponse = await fetch(warmupUrl, warmupInit);
642
+ const transformed = await transformAntigravityResponse(warmupResponse, true, warmupDebugContext, prepared.requestedModel, projectId, warmupUrl, prepared.effectiveModel, prepared.sessionId);
643
+ await transformed.text();
644
+ pushDebug("thinking-warmup: done");
645
+ }
646
+ catch (error) {
647
+ untrackWarmupSession(prepared.sessionId);
648
+ pushDebug(`thinking-warmup: failed ${error instanceof Error ? error.message : String(error)}`);
649
+ }
650
+ };
651
+ // Try endpoint fallbacks with header style fallback for Gemini
652
+ let shouldSwitchAccount = false;
653
+ // For Gemini models, we can try both header styles (antigravity first, then gemini-cli)
654
+ // For Claude models, only antigravity headers work
655
+ const headerStyles = family === "gemini"
656
+ ? ["antigravity", "gemini-cli"]
657
+ : ["antigravity"];
658
+ let currentHeaderStyleIndex = 0;
659
+ // Find first non-rate-limited header style for this account
660
+ while (currentHeaderStyleIndex < headerStyles.length) {
661
+ const hs = headerStyles[currentHeaderStyleIndex];
662
+ if (hs && !accountManager.isRateLimitedForHeaderStyle(account, family, hs)) {
663
+ break;
664
+ }
665
+ currentHeaderStyleIndex++;
666
+ }
667
+ // If all header styles are rate-limited for this account, switch account
668
+ if (currentHeaderStyleIndex >= headerStyles.length) {
669
+ shouldSwitchAccount = true;
670
+ }
671
+ headerStyleLoop: while (!shouldSwitchAccount && currentHeaderStyleIndex < headerStyles.length) {
672
+ const currentHeaderStyle = headerStyles[currentHeaderStyleIndex];
673
+ pushDebug(`headerStyle=${currentHeaderStyle}`);
674
+ for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
675
+ const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
407
676
  try {
408
- await accountManager.saveToDisk();
677
+ const prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, currentHeaderStyle);
678
+ const originalUrl = toUrlString(input);
679
+ const resolvedUrl = toUrlString(prepared.request);
680
+ pushDebug(`endpoint=${currentEndpoint}`);
681
+ pushDebug(`resolved=${resolvedUrl}`);
682
+ const debugContext = startAntigravityDebugRequest({
683
+ originalUrl,
684
+ resolvedUrl,
685
+ method: prepared.init.method,
686
+ headers: prepared.init.headers,
687
+ body: prepared.init.body,
688
+ streaming: prepared.streaming,
689
+ projectId: projectContext.effectiveProjectId,
690
+ });
691
+ await runThinkingWarmup(prepared, projectContext.effectiveProjectId);
692
+ const response = await fetch(prepared.request, prepared.init);
693
+ pushDebug(`status=${response.status} ${response.statusText}`);
694
+ // Handle 429 rate limit with improved logic
695
+ if (response.status === 429) {
696
+ const headerRetryMs = retryAfterMsFromResponse(response);
697
+ const bodyInfo = await extractRetryInfoFromBody(response);
698
+ const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs;
699
+ const { attempt, delayMs } = getRateLimitBackoff(account.index, serverRetryMs);
700
+ const waitTimeFormatted = formatWaitTime(delayMs);
701
+ const isCapacityExhausted = bodyInfo.reason === "MODEL_CAPACITY_EXHAUSTED" ||
702
+ (typeof bodyInfo.message === "string" && bodyInfo.message.toLowerCase().includes("no capacity"));
703
+ pushDebug(`429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${delayMs} attempt=${attempt}`);
704
+ if (bodyInfo.message) {
705
+ pushDebug(`429 message=${bodyInfo.message}`);
706
+ }
707
+ if (bodyInfo.quotaResetTime) {
708
+ pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`);
709
+ }
710
+ if (bodyInfo.reason) {
711
+ pushDebug(`429 reason=${bodyInfo.reason}`);
712
+ }
713
+ logRateLimitEvent(account.index, account.email, family, response.status, delayMs, bodyInfo);
714
+ await logResponseBody(debugContext, response, 429);
715
+ if (isCapacityExhausted) {
716
+ accountManager.markRateLimited(account, delayMs, family, currentHeaderStyle);
717
+ await showToast(`Model capacity exhausted for ${family}. Retrying in ${waitTimeFormatted} (attempt ${attempt})...`, "warning");
718
+ await sleep(delayMs, abortSignal);
719
+ continue;
720
+ }
721
+ const accountLabel = account.email || `Account ${account.index + 1}`;
722
+ // Short retry: if delay is small, just wait and retry same account
723
+ if (delayMs <= SHORT_RETRY_THRESHOLD_MS) {
724
+ await showToast(`Rate limited. Retrying in ${waitTimeFormatted} (attempt ${attempt})...`, "warning");
725
+ await sleep(delayMs, abortSignal);
726
+ continue;
727
+ }
728
+ // Mark this header style as rate-limited for this account
729
+ accountManager.markRateLimited(account, delayMs, family, currentHeaderStyle);
730
+ try {
731
+ await accountManager.saveToDisk();
732
+ }
733
+ catch (error) {
734
+ console.error("[opencode-antigravity-auth] Failed to persist rate-limit state:", error);
735
+ }
736
+ // For Gemini, try next header style before switching accounts
737
+ if (family === "gemini" && currentHeaderStyleIndex < headerStyles.length - 1) {
738
+ const nextHeaderStyle = headerStyles[currentHeaderStyleIndex + 1];
739
+ await showToast(`Rate limited on ${currentHeaderStyle} quota. Trying ${nextHeaderStyle} quota...`, "warning");
740
+ currentHeaderStyleIndex++;
741
+ continue headerStyleLoop;
742
+ }
743
+ if (accountCount > 1) {
744
+ const quotaMsg = bodyInfo.quotaResetTime
745
+ ? ` (quota resets ${bodyInfo.quotaResetTime})`
746
+ : ` (retry in ${waitTimeFormatted})`;
747
+ await showToast(`Rate limited on ${accountLabel}${quotaMsg}. Switching...`, "warning");
748
+ lastFailure = {
749
+ response,
750
+ streaming: prepared.streaming,
751
+ debugContext,
752
+ requestedModel: prepared.requestedModel,
753
+ projectId: prepared.projectId,
754
+ endpoint: prepared.endpoint,
755
+ effectiveModel: prepared.effectiveModel,
756
+ sessionId: prepared.sessionId,
757
+ toolDebugMissing: prepared.toolDebugMissing,
758
+ toolDebugSummary: prepared.toolDebugSummary,
759
+ toolDebugPayload: prepared.toolDebugPayload,
760
+ };
761
+ shouldSwitchAccount = true;
762
+ break;
763
+ }
764
+ else {
765
+ const quotaMsg = bodyInfo.quotaResetTime
766
+ ? `Quota resets ${bodyInfo.quotaResetTime}`
767
+ : `Waiting ${waitTimeFormatted}`;
768
+ await showToast(`Rate limited. ${quotaMsg} (attempt ${attempt})...`, "warning");
769
+ lastFailure = {
770
+ response,
771
+ streaming: prepared.streaming,
772
+ debugContext,
773
+ requestedModel: prepared.requestedModel,
774
+ projectId: prepared.projectId,
775
+ endpoint: prepared.endpoint,
776
+ effectiveModel: prepared.effectiveModel,
777
+ sessionId: prepared.sessionId,
778
+ toolDebugMissing: prepared.toolDebugMissing,
779
+ toolDebugSummary: prepared.toolDebugSummary,
780
+ toolDebugPayload: prepared.toolDebugPayload,
781
+ };
782
+ await sleep(delayMs, abortSignal);
783
+ shouldSwitchAccount = true;
784
+ break;
785
+ }
786
+ }
787
+ // Success - reset rate limit backoff state
788
+ resetRateLimitState(account.index);
789
+ resetAccountFailureState(account.index);
790
+ const shouldRetryEndpoint = (response.status === 403 ||
791
+ response.status === 404 ||
792
+ response.status >= 500);
793
+ if (shouldRetryEndpoint) {
794
+ await logResponseBody(debugContext, response, response.status);
795
+ }
796
+ if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
797
+ lastFailure = {
798
+ response,
799
+ streaming: prepared.streaming,
800
+ debugContext,
801
+ requestedModel: prepared.requestedModel,
802
+ projectId: prepared.projectId,
803
+ endpoint: prepared.endpoint,
804
+ effectiveModel: prepared.effectiveModel,
805
+ sessionId: prepared.sessionId,
806
+ toolDebugMissing: prepared.toolDebugMissing,
807
+ toolDebugSummary: prepared.toolDebugSummary,
808
+ toolDebugPayload: prepared.toolDebugPayload,
809
+ };
810
+ continue;
811
+ }
812
+ // Success or non-retryable error - return the response
813
+ logAntigravityDebugResponse(debugContext, response, {
814
+ note: response.ok ? "Success" : `Error ${response.status}`,
815
+ });
816
+ if (!response.ok) {
817
+ await logResponseBody(debugContext, response, response.status);
818
+ }
819
+ return transformAntigravityResponse(response, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload, debugLines);
409
820
  }
410
821
  catch (error) {
411
- console.error("[opencode-antigravity-auth] Failed to persist rate-limit state:", error);
412
- }
413
- const accountLabel = account.email || `Account ${account.index + 1}`;
414
- if (accountManager.getAccountCount() > 1) {
415
- // Multiple accounts - switch to next
416
- await showToast(`Rate limited on ${accountLabel}. Switching...`, "warning");
417
- lastFailure = {
418
- response,
419
- streaming: prepared.streaming,
420
- debugContext,
421
- requestedModel: prepared.requestedModel,
422
- projectId: prepared.projectId,
423
- endpoint: prepared.endpoint,
424
- effectiveModel: prepared.effectiveModel,
425
- sessionId: prepared.sessionId,
426
- toolDebugMissing: prepared.toolDebugMissing,
427
- toolDebugSummary: prepared.toolDebugSummary,
428
- toolDebugPayload: prepared.toolDebugPayload,
429
- };
822
+ if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
823
+ lastError = error instanceof Error ? error : new Error(String(error));
824
+ continue;
825
+ }
826
+ // All endpoints failed for this account - track failure and try next account
827
+ const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
828
+ lastError = error instanceof Error ? error : new Error(String(error));
829
+ if (shouldCooldown) {
830
+ accountManager.markRateLimited(account, cooldownMs, family, currentHeaderStyle);
831
+ pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`);
832
+ }
430
833
  shouldSwitchAccount = true;
431
834
  break;
432
835
  }
433
- else {
434
- // Single account - wait and retry
435
- const waitSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
436
- await showToast(`Rate limited. Waiting ${waitSec}s...`, "warning");
437
- lastFailure = {
438
- response,
439
- streaming: prepared.streaming,
440
- debugContext,
441
- requestedModel: prepared.requestedModel,
442
- projectId: prepared.projectId,
443
- endpoint: prepared.endpoint,
444
- effectiveModel: prepared.effectiveModel,
445
- sessionId: prepared.sessionId,
446
- toolDebugMissing: prepared.toolDebugMissing,
447
- toolDebugSummary: prepared.toolDebugSummary,
448
- toolDebugPayload: prepared.toolDebugPayload,
836
+ }
837
+ } // end headerStyleLoop
838
+ if (shouldSwitchAccount) {
839
+ continue;
840
+ }
841
+ // If we get here without returning, something went wrong
842
+ if (lastFailure) {
843
+ 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);
844
+ }
845
+ throw lastError || new Error("All Antigravity accounts failed");
846
+ }
847
+ },
848
+ };
849
+ },
850
+ methods: [
851
+ {
852
+ label: "OAuth with Google (Antigravity)",
853
+ type: "oauth",
854
+ authorize: async (inputs) => {
855
+ const isHeadless = !!(process.env.SSH_CONNECTION ||
856
+ process.env.SSH_CLIENT ||
857
+ process.env.SSH_TTY ||
858
+ process.env.OPENCODE_HEADLESS);
859
+ // CLI flow (`opencode auth login`) passes an inputs object.
860
+ if (inputs) {
861
+ const accounts = [];
862
+ // Check for existing accounts and prompt user for login mode
863
+ let startFresh = true;
864
+ const existingStorage = await loadAccounts();
865
+ if (existingStorage && existingStorage.accounts.length > 0) {
866
+ const existingAccounts = existingStorage.accounts.map((acc, idx) => ({
867
+ email: acc.email,
868
+ index: idx,
869
+ }));
870
+ const loginMode = await promptLoginMode(existingAccounts);
871
+ startFresh = loginMode === "fresh";
872
+ if (startFresh) {
873
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
874
+ }
875
+ else {
876
+ console.log("\nAdding to existing accounts.\n");
877
+ }
878
+ }
879
+ while (accounts.length < MAX_OAUTH_ACCOUNTS) {
880
+ console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);
881
+ const projectId = await promptProjectId();
882
+ const result = await (async () => {
883
+ let listener = null;
884
+ if (!isHeadless) {
885
+ try {
886
+ listener = await startOAuthListener();
887
+ }
888
+ catch {
889
+ listener = null;
890
+ }
891
+ }
892
+ const authorization = await authorizeAntigravity(projectId);
893
+ const fallbackState = getStateFromAuthorizationUrl(authorization.url);
894
+ console.log("\nOAuth URL:\n" + authorization.url + "\n");
895
+ if (!isHeadless) {
896
+ await openBrowser(authorization.url);
897
+ }
898
+ if (listener) {
899
+ try {
900
+ const callbackUrl = await listener.waitForCallback();
901
+ const params = extractOAuthCallbackParams(callbackUrl);
902
+ if (!params) {
903
+ return { type: "failed", error: "Missing code or state in callback URL" };
904
+ }
905
+ return exchangeAntigravity(params.code, params.state);
906
+ }
907
+ catch (error) {
908
+ return {
909
+ type: "failed",
910
+ error: error instanceof Error ? error.message : "Unknown error",
911
+ };
912
+ }
913
+ finally {
914
+ try {
915
+ await listener.close();
916
+ }
917
+ catch {
918
+ // ignore
919
+ }
920
+ }
921
+ }
922
+ console.log("1. Open the URL below in your browser and complete Google sign-in.");
923
+ console.log("2. After approving, copy the full redirected localhost URL from the address bar.");
924
+ console.log("3. Paste it back here.");
925
+ const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
926
+ const params = parseOAuthCallbackInput(callbackInput, fallbackState);
927
+ if ("error" in params) {
928
+ return { type: "failed", error: params.error };
929
+ }
930
+ return exchangeAntigravity(params.code, params.state);
931
+ })();
932
+ if (result.type === "failed") {
933
+ if (accounts.length === 0) {
934
+ return {
935
+ url: "",
936
+ instructions: `Authentication failed: ${result.error}`,
937
+ method: "auto",
938
+ callback: async () => result,
449
939
  };
450
- // Wait and let the outer loop retry
451
- await sleep(retryAfterMs, abortSignal);
452
- shouldSwitchAccount = true;
453
- break;
454
940
  }
941
+ console.warn(`[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
942
+ break;
455
943
  }
456
- const shouldRetryEndpoint = (response.status === 403 ||
457
- response.status === 404 ||
458
- response.status >= 500);
459
- if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
460
- lastFailure = {
461
- response,
462
- streaming: prepared.streaming,
463
- debugContext,
464
- requestedModel: prepared.requestedModel,
465
- projectId: prepared.projectId,
466
- endpoint: prepared.endpoint,
467
- effectiveModel: prepared.effectiveModel,
468
- sessionId: prepared.sessionId,
469
- toolDebugMissing: prepared.toolDebugMissing,
470
- toolDebugSummary: prepared.toolDebugSummary,
471
- toolDebugPayload: prepared.toolDebugPayload,
472
- };
473
- continue;
944
+ accounts.push(result);
945
+ // Show toast for successful account authentication
946
+ try {
947
+ await client.tui.showToast({
948
+ body: {
949
+ message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`,
950
+ variant: "success",
951
+ },
952
+ });
474
953
  }
475
- // Success or non-retryable error - return the response
476
- return transformAntigravityResponse(response, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload);
477
- }
478
- catch (error) {
479
- if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
480
- lastError = error instanceof Error ? error : new Error(String(error));
481
- continue;
954
+ catch {
955
+ // TUI may not be available in CLI mode
956
+ }
957
+ try {
958
+ // Use startFresh only on first account, subsequent accounts always append
959
+ const isFirstAccount = accounts.length === 1;
960
+ await persistAccountPool([result], isFirstAccount && startFresh);
961
+ }
962
+ catch {
963
+ // ignore
964
+ }
965
+ if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
966
+ break;
967
+ }
968
+ const addAnother = await promptAddAnotherAccount(accounts.length);
969
+ if (!addAnother) {
970
+ break;
482
971
  }
483
- // All endpoints failed for this account - try next account
484
- lastError = error instanceof Error ? error : new Error(String(error));
485
- shouldSwitchAccount = true;
486
- break;
487
972
  }
973
+ const primary = accounts[0];
974
+ if (!primary) {
975
+ return {
976
+ url: "",
977
+ instructions: "Authentication cancelled",
978
+ method: "auto",
979
+ callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
980
+ };
981
+ }
982
+ return {
983
+ url: "",
984
+ instructions: `Multi-account setup complete (${accounts.length} account(s)).`,
985
+ method: "auto",
986
+ callback: async () => primary,
987
+ };
488
988
  }
489
- if (shouldSwitchAccount) {
490
- continue;
491
- }
492
- // If we get here without returning, something went wrong
493
- if (lastFailure) {
494
- return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload);
495
- }
496
- throw lastError || new Error("All Antigravity accounts failed");
497
- }
498
- },
499
- };
500
- },
501
- methods: [
502
- {
503
- label: "OAuth with Google (Antigravity)",
504
- type: "oauth",
505
- authorize: async (inputs) => {
506
- const isHeadless = !!(process.env.SSH_CONNECTION ||
507
- process.env.SSH_CLIENT ||
508
- process.env.SSH_TTY ||
509
- process.env.OPENCODE_HEADLESS);
510
- // CLI flow (`opencode auth login`) passes an inputs object.
511
- if (inputs) {
512
- const accounts = [];
513
- // Check for existing accounts and prompt user for login mode
514
- let startFresh = true;
989
+ // TUI flow (`/connect`) does not support per-account prompts.
990
+ // Default to adding new accounts (non-destructive).
991
+ // Users can run `opencode auth logout` first if they want a fresh start.
992
+ const projectId = "";
993
+ // Check existing accounts count for toast message
515
994
  const existingStorage = await loadAccounts();
516
- if (existingStorage && existingStorage.accounts.length > 0) {
517
- const existingAccounts = existingStorage.accounts.map((acc, idx) => ({
518
- email: acc.email,
519
- index: idx,
520
- }));
521
- const loginMode = await promptLoginMode(existingAccounts);
522
- startFresh = loginMode === "fresh";
523
- if (startFresh) {
524
- console.log("\nStarting fresh - existing accounts will be replaced.\n");
995
+ const existingCount = existingStorage?.accounts.length ?? 0;
996
+ let listener = null;
997
+ if (!isHeadless) {
998
+ try {
999
+ listener = await startOAuthListener();
525
1000
  }
526
- else {
527
- console.log("\nAdding to existing accounts.\n");
1001
+ catch {
1002
+ listener = null;
528
1003
  }
529
1004
  }
530
- while (accounts.length < MAX_OAUTH_ACCOUNTS) {
531
- console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);
532
- const projectId = await promptProjectId();
533
- const result = await (async () => {
534
- let listener = null;
535
- if (!isHeadless) {
536
- try {
537
- listener = await startOAuthListener();
538
- }
539
- catch {
540
- listener = null;
541
- }
542
- }
543
- const authorization = await authorizeAntigravity(projectId);
544
- const fallbackState = getStateFromAuthorizationUrl(authorization.url);
545
- console.log("\nOAuth URL:\n" + authorization.url + "\n");
546
- if (!isHeadless) {
547
- await openBrowser(authorization.url);
548
- }
549
- if (listener) {
1005
+ const authorization = await authorizeAntigravity(projectId);
1006
+ const fallbackState = getStateFromAuthorizationUrl(authorization.url);
1007
+ if (!isHeadless) {
1008
+ await openBrowser(authorization.url);
1009
+ }
1010
+ if (listener) {
1011
+ return {
1012
+ url: authorization.url,
1013
+ instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
1014
+ method: "auto",
1015
+ callback: async () => {
550
1016
  try {
551
1017
  const callbackUrl = await listener.waitForCallback();
552
1018
  const params = extractOAuthCallbackParams(callbackUrl);
553
1019
  if (!params) {
554
1020
  return { type: "failed", error: "Missing code or state in callback URL" };
555
1021
  }
556
- return exchangeAntigravity(params.code, params.state);
1022
+ const result = await exchangeAntigravity(params.code, params.state);
1023
+ if (result.type === "success") {
1024
+ try {
1025
+ // TUI flow adds to existing accounts (non-destructive)
1026
+ await persistAccountPool([result], false);
1027
+ }
1028
+ catch {
1029
+ // ignore
1030
+ }
1031
+ // Show appropriate toast message
1032
+ const newTotal = existingCount + 1;
1033
+ const toastMessage = existingCount > 0
1034
+ ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
1035
+ : `Authenticated${result.email ? ` (${result.email})` : ""}`;
1036
+ try {
1037
+ await client.tui.showToast({
1038
+ body: {
1039
+ message: toastMessage,
1040
+ variant: "success",
1041
+ },
1042
+ });
1043
+ }
1044
+ catch {
1045
+ // TUI may not be available
1046
+ }
1047
+ }
1048
+ return result;
557
1049
  }
558
1050
  catch (error) {
559
1051
  return {
@@ -569,199 +1061,57 @@ export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
569
1061
  // ignore
570
1062
  }
571
1063
  }
572
- }
573
- console.log("1. Open the URL below in your browser and complete Google sign-in.");
574
- console.log("2. After approving, copy the full redirected localhost URL from the address bar.");
575
- console.log("3. Paste it back here.");
576
- const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
577
- const params = parseOAuthCallbackInput(callbackInput, fallbackState);
578
- if ("error" in params) {
579
- return { type: "failed", error: params.error };
580
- }
581
- return exchangeAntigravity(params.code, params.state);
582
- })();
583
- if (result.type === "failed") {
584
- if (accounts.length === 0) {
585
- return {
586
- url: "",
587
- instructions: `Authentication failed: ${result.error}`,
588
- method: "auto",
589
- callback: async () => result,
590
- };
591
- }
592
- console.warn(`[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
593
- break;
594
- }
595
- accounts.push(result);
596
- // Show toast for successful account authentication
597
- try {
598
- await client.tui.showToast({
599
- body: {
600
- message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`,
601
- variant: "success",
602
- },
603
- });
604
- }
605
- catch {
606
- // TUI may not be available in CLI mode
607
- }
608
- try {
609
- // Use startFresh only on first account, subsequent accounts always append
610
- const isFirstAccount = accounts.length === 1;
611
- await persistAccountPool([result], isFirstAccount && startFresh);
612
- }
613
- catch {
614
- // ignore
615
- }
616
- if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
617
- break;
618
- }
619
- const addAnother = await promptAddAnotherAccount(accounts.length);
620
- if (!addAnother) {
621
- break;
622
- }
623
- }
624
- const primary = accounts[0];
625
- if (!primary) {
626
- return {
627
- url: "",
628
- instructions: "Authentication cancelled",
629
- method: "auto",
630
- callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
1064
+ },
631
1065
  };
632
1066
  }
633
- return {
634
- url: "",
635
- instructions: `Multi-account setup complete (${accounts.length} account(s)).`,
636
- method: "auto",
637
- callback: async () => primary,
638
- };
639
- }
640
- // TUI flow (`/connect`) does not support per-account prompts.
641
- // Default to adding new accounts (non-destructive).
642
- // Users can run `opencode auth logout` first if they want a fresh start.
643
- const projectId = "";
644
- // Check existing accounts count for toast message
645
- const existingStorage = await loadAccounts();
646
- const existingCount = existingStorage?.accounts.length ?? 0;
647
- let listener = null;
648
- if (!isHeadless) {
649
- try {
650
- listener = await startOAuthListener();
651
- }
652
- catch {
653
- listener = null;
654
- }
655
- }
656
- const authorization = await authorizeAntigravity(projectId);
657
- const fallbackState = getStateFromAuthorizationUrl(authorization.url);
658
- if (!isHeadless) {
659
- await openBrowser(authorization.url);
660
- }
661
- if (listener) {
662
1067
  return {
663
1068
  url: authorization.url,
664
- instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
665
- method: "auto",
666
- callback: async () => {
667
- try {
668
- const callbackUrl = await listener.waitForCallback();
669
- const params = extractOAuthCallbackParams(callbackUrl);
670
- if (!params) {
671
- return { type: "failed", error: "Missing code or state in callback URL" };
672
- }
673
- const result = await exchangeAntigravity(params.code, params.state);
674
- if (result.type === "success") {
675
- try {
676
- // TUI flow adds to existing accounts (non-destructive)
677
- await persistAccountPool([result], false);
678
- }
679
- catch {
680
- // ignore
681
- }
682
- // Show appropriate toast message
683
- const newTotal = existingCount + 1;
684
- const toastMessage = existingCount > 0
685
- ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
686
- : `Authenticated${result.email ? ` (${result.email})` : ""}`;
687
- try {
688
- await client.tui.showToast({
689
- body: {
690
- message: toastMessage,
691
- variant: "success",
692
- },
693
- });
694
- }
695
- catch {
696
- // TUI may not be available
697
- }
698
- }
699
- return result;
700
- }
701
- catch (error) {
702
- return {
703
- type: "failed",
704
- error: error instanceof Error ? error.message : "Unknown error",
705
- };
1069
+ instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
1070
+ method: "code",
1071
+ callback: async (codeInput) => {
1072
+ const params = parseOAuthCallbackInput(codeInput, fallbackState);
1073
+ if ("error" in params) {
1074
+ return { type: "failed", error: params.error };
706
1075
  }
707
- finally {
1076
+ const result = await exchangeAntigravity(params.code, params.state);
1077
+ if (result.type === "success") {
708
1078
  try {
709
- await listener.close();
1079
+ // TUI flow adds to existing accounts (non-destructive)
1080
+ await persistAccountPool([result], false);
710
1081
  }
711
1082
  catch {
712
1083
  // ignore
713
1084
  }
1085
+ // Show appropriate toast message
1086
+ const newTotal = existingCount + 1;
1087
+ const toastMessage = existingCount > 0
1088
+ ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
1089
+ : `Authenticated${result.email ? ` (${result.email})` : ""}`;
1090
+ try {
1091
+ await client.tui.showToast({
1092
+ body: {
1093
+ message: toastMessage,
1094
+ variant: "success",
1095
+ },
1096
+ });
1097
+ }
1098
+ catch {
1099
+ // TUI may not be available
1100
+ }
714
1101
  }
1102
+ return result;
715
1103
  },
716
1104
  };
717
- }
718
- return {
719
- url: authorization.url,
720
- instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
721
- method: "code",
722
- callback: async (codeInput) => {
723
- const params = parseOAuthCallbackInput(codeInput, fallbackState);
724
- if ("error" in params) {
725
- return { type: "failed", error: params.error };
726
- }
727
- const result = await exchangeAntigravity(params.code, params.state);
728
- if (result.type === "success") {
729
- try {
730
- // TUI flow adds to existing accounts (non-destructive)
731
- await persistAccountPool([result], false);
732
- }
733
- catch {
734
- // ignore
735
- }
736
- // Show appropriate toast message
737
- const newTotal = existingCount + 1;
738
- const toastMessage = existingCount > 0
739
- ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
740
- : `Authenticated${result.email ? ` (${result.email})` : ""}`;
741
- try {
742
- await client.tui.showToast({
743
- body: {
744
- message: toastMessage,
745
- variant: "success",
746
- },
747
- });
748
- }
749
- catch {
750
- // TUI may not be available
751
- }
752
- }
753
- return result;
754
- },
755
- };
1105
+ },
756
1106
  },
757
- },
758
- {
759
- label: "Manually enter API Key",
760
- type: "api",
761
- },
762
- ],
763
- },
764
- });
1107
+ {
1108
+ label: "Manually enter API Key",
1109
+ type: "api",
1110
+ },
1111
+ ],
1112
+ },
1113
+ };
1114
+ };
765
1115
  export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);
766
1116
  export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;
767
1117
  function toUrlString(value) {
@@ -774,4 +1124,33 @@ function toUrlString(value) {
774
1124
  }
775
1125
  return value.toString();
776
1126
  }
1127
+ function toWarmupStreamUrl(value) {
1128
+ const urlString = toUrlString(value);
1129
+ try {
1130
+ const url = new URL(urlString);
1131
+ if (!url.pathname.includes(":streamGenerateContent")) {
1132
+ url.pathname = url.pathname.replace(":generateContent", ":streamGenerateContent");
1133
+ }
1134
+ url.searchParams.set("alt", "sse");
1135
+ return url.toString();
1136
+ }
1137
+ catch {
1138
+ return urlString;
1139
+ }
1140
+ }
1141
+ function extractModelFromUrl(urlString) {
1142
+ const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/);
1143
+ return match?.[1] ?? null;
1144
+ }
1145
+ function getModelFamilyFromUrl(urlString) {
1146
+ const model = extractModelFromUrl(urlString);
1147
+ let family = "gemini";
1148
+ if (model && model.includes("claude")) {
1149
+ family = "claude";
1150
+ }
1151
+ if (isDebugEnabled()) {
1152
+ logModelFamily(urlString, model, family);
1153
+ }
1154
+ return family;
1155
+ }
777
1156
  //# sourceMappingURL=plugin.js.map