opencode-antigravity-auth 1.2.0 → 1.2.1

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