opencode-antigravity-auth 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +34 -718
  2. package/dist/index.d.ts +1 -1
  3. package/dist/src/plugin/account-management.d.ts +67 -0
  4. package/dist/src/plugin/account-management.d.ts.map +1 -0
  5. package/dist/src/plugin/account-management.js +174 -0
  6. package/dist/src/plugin/account-management.js.map +1 -0
  7. package/dist/src/plugin/account-rpc.d.ts +70 -0
  8. package/dist/src/plugin/account-rpc.d.ts.map +1 -0
  9. package/dist/src/plugin/account-rpc.js +28 -0
  10. package/dist/src/plugin/account-rpc.js.map +1 -0
  11. package/dist/src/plugin/accounts.d.ts +23 -9
  12. package/dist/src/plugin/accounts.d.ts.map +1 -1
  13. package/dist/src/plugin/accounts.js +190 -22
  14. package/dist/src/plugin/accounts.js.map +1 -1
  15. package/dist/src/plugin/config/models-v2.d.ts +19 -1
  16. package/dist/src/plugin/config/models-v2.d.ts.map +1 -1
  17. package/dist/src/plugin/config/models-v2.js +49 -38
  18. package/dist/src/plugin/config/models-v2.js.map +1 -1
  19. package/dist/src/plugin/quota.d.ts +33 -1
  20. package/dist/src/plugin/quota.d.ts.map +1 -1
  21. package/dist/src/plugin/quota.js +43 -18
  22. package/dist/src/plugin/quota.js.map +1 -1
  23. package/dist/src/plugin/refresh-queue.d.ts +4 -1
  24. package/dist/src/plugin/refresh-queue.d.ts.map +1 -1
  25. package/dist/src/plugin/refresh-queue.js +21 -4
  26. package/dist/src/plugin/refresh-queue.js.map +1 -1
  27. package/dist/src/plugin/storage.d.ts +15 -0
  28. package/dist/src/plugin/storage.d.ts.map +1 -1
  29. package/dist/src/plugin/storage.js +61 -2
  30. package/dist/src/plugin/storage.js.map +1 -1
  31. package/dist/src/plugin/token.d.ts +1 -3
  32. package/dist/src/plugin/token.d.ts.map +1 -1
  33. package/dist/src/plugin/token.js +43 -3
  34. package/dist/src/plugin/token.js.map +1 -1
  35. package/dist/src/plugin/transform/model-resolver.d.ts +2 -5
  36. package/dist/src/plugin/transform/model-resolver.d.ts.map +1 -1
  37. package/dist/src/plugin/transform/model-resolver.js +9 -1
  38. package/dist/src/plugin/transform/model-resolver.js.map +1 -1
  39. package/dist/src/plugin/types.d.ts +5 -0
  40. package/dist/src/plugin/types.d.ts.map +1 -1
  41. package/dist/src/plugin.d.ts +3 -3
  42. package/dist/src/plugin.d.ts.map +1 -1
  43. package/dist/src/plugin.js +84 -26
  44. package/dist/src/plugin.js.map +1 -1
  45. package/dist/src/tui.d.ts +64 -0
  46. package/dist/src/tui.d.ts.map +1 -0
  47. package/dist/src/tui.js +124 -0
  48. package/dist/src/tui.js.map +1 -0
  49. package/dist/src/v2.d.ts.map +1 -1
  50. package/dist/src/v2.js +386 -105
  51. package/dist/src/v2.js.map +1 -1
  52. package/package.json +9 -3
  53. package/tui.js +1 -0
package/dist/src/v2.js CHANGED
@@ -5,14 +5,18 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google";
5
5
  import { Credential, Integration, Model, Plugin, Provider } from "@opencode/plugin";
6
6
  import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth.js";
7
7
  import { ANTIGRAVITY_REDIRECT_URI } from "./constants.js";
8
- import { createAntigravityPlugin, persistAccountPool } from "./plugin.js";
8
+ import { createAntigravityPlugin } from "./plugin.js";
9
9
  import { accessTokenExpired, formatRefreshParts, parseRefreshParts } from "./plugin/auth.js";
10
- import { OPENCODE_V2_MODEL_DEFINITIONS } from "./plugin/config/models-v2.js";
10
+ import { OPENCODE_V2_MODEL_DEFINITIONS, buildAccountModelDefinitions, ModelCatalogCacheSchema } from "./plugin/config/models-v2.js";
11
+ import { accountID, accountPoolIdentity, accountCredentialsIdentity, loadManagedAccounts, registerAccountManagement } from "./plugin/account-management.js";
12
+ import { checkAccountsQuota } from "./plugin/quota.js";
13
+ import { createLogger } from "./plugin/logger.js";
11
14
  import { ensureProjectContext } from "./plugin/project.js";
12
15
  import { executeSearch } from "./plugin/search.js";
13
16
  import { startOAuthListener } from "./plugin/server.js";
14
- import { loadAccounts, saveAccounts } from "./plugin/storage.js";
17
+ import { loadAccounts, matchesAccountToken, saveAccountCredentials, updateAccountCredentials, updateAccounts } from "./plugin/storage.js";
15
18
  import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token.js";
19
+ import { resolveModelWithTier } from "./plugin/transform/model-resolver.js";
16
20
  const PROVIDER_ID = "google";
17
21
  const METHOD_ID = "antigravity";
18
22
  // Core rewrites the standard Google SDK package to its native transport after
@@ -69,10 +73,16 @@ export const AntigravityV2Plugin = Plugin.define({
69
73
  async setup(ctx) {
70
74
  const client = transportClient();
71
75
  const outbound = new AsyncLocalStorage();
76
+ let modelAccounts = {};
77
+ let accountCatalogLoaded = false;
72
78
  const transport = await createAntigravityPlugin(PROVIDER_ID)({
73
79
  client,
74
80
  directory: ctx.location.directory,
75
81
  runtimeVersion: 2,
82
+ canUseAccount: (account, model) => {
83
+ const eligible = modelAccounts[`antigravity-${resolveModelWithTier(model).actualModel}`];
84
+ return !accountCatalogLoaded || !!eligible?.includes(accountID(account));
85
+ },
76
86
  fetch: (input, init) => {
77
87
  const send = outbound.getStore() ?? globalThis.fetch;
78
88
  // Core's wrapper applies model.body to string init.body. It has already
@@ -84,11 +94,115 @@ export const AntigravityV2Plugin = Plugin.define({
84
94
  },
85
95
  });
86
96
  const registrations = [];
87
- const saved = await loadAccounts();
88
- const hasSavedAccounts = saved?.accounts.some((account) => account.enabled !== false && !!account.refreshToken);
97
+ const saved = await loadManagedAccounts();
98
+ let hasSavedAccounts = saved?.accounts.some((account) => account.enabled !== false && !!account.refreshToken);
99
+ const log = createLogger("v2");
100
+ const cachedResult = ModelCatalogCacheSchema.safeParse(await ctx.storage.get("model-catalog"));
101
+ const cached = cachedResult.success ? cachedResult.data : undefined;
102
+ const validCache = cached?.identity === accountPoolIdentity(saved?.accounts ?? []) && cached.models;
103
+ let modelDefinitions = validCache ? buildAccountModelDefinitions(validCache) : OPENCODE_V2_MODEL_DEFINITIONS;
104
+ accountCatalogLoaded = !!validCache;
105
+ if (validCache)
106
+ modelAccounts = cached.accounts;
107
+ let discoveryGeneration = 0;
108
+ let catalogSnapshot;
109
+ const catalogWrites = new Set();
110
+ const discoveries = new Set();
111
+ const writeCatalog = async () => {
112
+ let pending = catalogSnapshot;
113
+ while (pending !== undefined) {
114
+ await ctx.storage.set("model-catalog", pending);
115
+ if (pending === catalogSnapshot)
116
+ return;
117
+ pending = catalogSnapshot;
118
+ }
119
+ };
120
+ const discoverModels = async () => {
121
+ const discovery = ++discoveryGeneration;
122
+ await resolveAuth();
123
+ const stored = await loadManagedAccounts();
124
+ const accounts = stored?.accounts.filter(account => account.enabled !== false) ?? [];
125
+ hasSavedAccounts = accounts.length > 0;
126
+ if (!accounts.length)
127
+ throw new Error("No enabled Google accounts. Add or enable an account first.");
128
+ const results = await checkAccountsQuota(accounts, client, PROVIDER_ID, {
129
+ onAccountUpdated: saveAccountCredentials,
130
+ signal: lifetime.signal,
131
+ });
132
+ const successful = results.filter(result => result.quota?.models && Object.keys(result.quota.models).length);
133
+ const models = {};
134
+ const eligibleAccounts = {};
135
+ if (discovery === discoveryGeneration) {
136
+ invalidateLoader();
137
+ await transport.flush?.();
138
+ }
139
+ const latest = await updateAccounts(latest => {
140
+ for (const result of results) {
141
+ const original = accounts[result.index];
142
+ if (!original)
143
+ continue;
144
+ const account = latest.accounts.find(item => accountID(item) === accountID(original));
145
+ if (!account)
146
+ continue;
147
+ if (result.updatedAccount && account.refreshToken === original.refreshToken) {
148
+ updateAccountCredentials(account, result.updatedAccount);
149
+ }
150
+ if (account.enabled === false)
151
+ continue;
152
+ if (!result.quota?.models)
153
+ continue;
154
+ for (const id of Object.keys(buildAccountModelDefinitions(result.quota.models))) {
155
+ (eligibleAccounts[id] ??= []).push(accountID(account));
156
+ }
157
+ for (const [id, model] of Object.entries(result.quota?.models ?? {})) {
158
+ const usable = (value) => !value.disabled && value.selectable !== false;
159
+ if (!models[id] || !usable(models[id]) || usable(model))
160
+ models[id] = model;
161
+ }
162
+ }
163
+ });
164
+ // Credentials remain valuable after a concurrent pool change, but only
165
+ // the newest complete discovery for this pool may replace the catalog.
166
+ if (disposed || discovery !== discoveryGeneration || accountPoolIdentity(latest?.accounts ?? []) !== accountPoolIdentity(accounts))
167
+ return Object.keys(modelDefinitions).length;
168
+ if (!successful.length)
169
+ throw new Error(results.find(result => result.error)?.error ?? "Google did not return a model catalog. Check account access and try again.");
170
+ modelDefinitions = buildAccountModelDefinitions(models);
171
+ modelAccounts = eligibleAccounts;
172
+ accountCatalogLoaded = true;
173
+ catalogSnapshot = JSON.parse(JSON.stringify({ version: 3, identity: accountPoolIdentity(latest?.accounts ?? []), updatedAt: Date.now(), models, accounts: eligibleAccounts }));
174
+ const writing = writeCatalog();
175
+ catalogWrites.add(writing);
176
+ try {
177
+ await writing;
178
+ }
179
+ finally {
180
+ catalogWrites.delete(writing);
181
+ }
182
+ await ctx.provider.reload();
183
+ return Object.keys(modelDefinitions).length;
184
+ };
185
+ const refreshModels = () => {
186
+ if (disposed)
187
+ return Promise.reject(new Error("Antigravity plugin has been disposed"));
188
+ const discovering = discoverModels();
189
+ discoveries.add(discovering);
190
+ void discovering.then(() => discoveries.delete(discovering), () => discoveries.delete(discovering));
191
+ return discovering;
192
+ };
89
193
  const listeners = new Set();
90
194
  const lifetime = new AbortController();
195
+ let requestLifetime = new AbortController();
91
196
  let disposed = false;
197
+ const credentialOperations = new Set();
198
+ const runCredentialOperation = (operation) => {
199
+ if (disposed)
200
+ return Promise.reject(new Error("Antigravity plugin has been disposed"));
201
+ const running = operation();
202
+ credentialOperations.add(running);
203
+ void running.then(() => credentialOperations.delete(running), () => credentialOperations.delete(running));
204
+ return running;
205
+ };
92
206
  let loader;
93
207
  let loaderIdentity;
94
208
  let generation = 0;
@@ -105,18 +219,37 @@ export const AntigravityV2Plugin = Plugin.define({
105
219
  };
106
220
  const resolveAuth = async () => {
107
221
  const connection = await ctx.integration.connection.active(PROVIDER_ID);
222
+ let poolSelection = await ctx.storage.get("pool-selection");
223
+ const connectionID = connection?.type === "credential" ? connection.id : "pool";
224
+ if (poolSelection && poolSelection !== connectionID) {
225
+ await ctx.storage.remove("pool-selection");
226
+ poolSelection = undefined;
227
+ }
108
228
  if (connection) {
109
229
  const value = await ctx.integration.connection.resolve(connection);
110
230
  const stored = await loadAccounts();
231
+ const bindingKey = connection.type === "credential" ? `connection-account:${connection.id}` : undefined;
232
+ const boundID = bindingKey ? await ctx.storage.get(bindingKey) : undefined;
111
233
  const migrated = value?.type === "oauth" && value.methodID === "oauth"
112
- && stored?.accounts.some((account) => account.refreshToken === parseRefreshParts(value.refresh).refreshToken);
234
+ && (typeof boundID === "string" || stored?.accounts.some((account) => matchesAccountToken(account, parseRefreshParts(value.refresh).refreshToken)));
113
235
  if (!isAntigravityCredential(value) && !migrated)
114
236
  return observeAuth(undefined);
115
237
  if (value?.type !== "oauth")
116
238
  return observeAuth(undefined);
117
239
  // V1 credentials are imported with a generic method ID. Only matching
118
240
  // this plugin's existing pool establishes their Antigravity ownership.
119
- return observeAuth({ auth: value, identity: `credential:${parseRefreshParts(value.refresh).refreshToken}`, select: true });
241
+ const metadataID = value.metadata?.antigravityAccountId;
242
+ const account = stored?.accounts.find(item => matchesAccountToken(item, parseRefreshParts(value.refresh).refreshToken))
243
+ ?? stored?.accounts.find(item => (typeof metadataID === "string" && item.id === metadataID) || (typeof boundID === "string" && item.id === boundID) || (typeof value.metadata?.email === "string" && item.email === value.metadata.email));
244
+ if (account && bindingKey)
245
+ await ctx.storage.set(bindingKey, accountID(account));
246
+ const knownAccountRemoved = (typeof metadataID === "string" || typeof boundID === "string") && !account;
247
+ if (account?.enabled !== false && poolSelection !== "pool" && (connection.type !== "credential" || poolSelection !== connection.id)) {
248
+ if (!knownAccountRemoved) {
249
+ const auth = account ? { ...value, refresh: formatRefreshParts(account) } : value;
250
+ return observeAuth({ auth, identity: `credential:${parseRefreshParts(auth.refresh).refreshToken}:${accountCredentialsIdentity(stored?.accounts ?? [])}`, select: true, accountID: account && accountID(account) });
251
+ }
252
+ }
120
253
  }
121
254
  // The independent pool also supports V1 migration without a connection.
122
255
  const stored = await loadAccounts();
@@ -126,22 +259,54 @@ export const AntigravityV2Plugin = Plugin.define({
126
259
  : stored?.accounts.find((item) => item.enabled !== false && item.refreshToken);
127
260
  return observeAuth(account ? {
128
261
  auth: { type: "oauth", refresh: formatRefreshParts(account) },
129
- identity: "account-pool",
262
+ // Cursor changes preserve rotation state; credential rotation rebuilds
263
+ // a manager before it can attempt to refresh an obsolete token.
264
+ identity: `account-pool:${accountCredentialsIdentity(stored?.accounts ?? [])}`,
130
265
  select: false,
266
+ accountID: accountID(account),
131
267
  } : undefined);
132
268
  };
133
269
  const currentAuth = async () => (await resolveAuth())?.auth;
134
- const invalidateLoader = () => { generation += 1; loader = undefined; loaderIdentity = undefined; };
135
- const rememberAuth = async (auth, email) => {
270
+ const invalidateLoader = (abortRequests = true) => {
271
+ generation += 1;
272
+ if (abortRequests) {
273
+ requestLifetime.abort(new Error("Antigravity accounts changed"));
274
+ requestLifetime = new AbortController();
275
+ }
276
+ loader = undefined;
277
+ loaderIdentity = undefined;
278
+ };
279
+ const rememberAuth = async (auth, email, previous) => {
136
280
  const parts = parseRefreshParts(auth.refresh);
137
- await persistAccountPool([{
138
- type: "success",
139
- refresh: auth.refresh,
140
- access: auth.access ?? "",
141
- expires: auth.expires ?? 0,
142
- projectId: parts.projectId ?? "",
143
- email,
144
- }]);
281
+ if (previous) {
282
+ let remembered = false;
283
+ const originalToken = parseRefreshParts(previous.refresh).refreshToken;
284
+ const saved = await updateAccounts(storage => {
285
+ const account = storage.accounts.find(item => matchesAccountToken(item, originalToken) || (typeof previous.metadata?.antigravityAccountId === "string" && item.id === previous.metadata.antigravityAccountId));
286
+ if (!account)
287
+ return;
288
+ remembered = true;
289
+ if (account.refreshToken === originalToken) {
290
+ updateAccountCredentials(account, parts);
291
+ }
292
+ });
293
+ if (remembered)
294
+ return saved?.accounts.find(item => item.refreshToken === parts.refreshToken || item.id === previous.metadata?.antigravityAccountId);
295
+ // A saved native credential may outlive an explicitly deleted pool entry.
296
+ if (previous.metadata?.antigravityAccountId)
297
+ return undefined;
298
+ }
299
+ const saved = await updateAccounts(storage => {
300
+ const account = storage.accounts.find(item => matchesAccountToken(item, parts.refreshToken) || (!!email && item.email === email));
301
+ if (account) {
302
+ updateAccountCredentials(account, parts);
303
+ account.email = email ?? account.email;
304
+ }
305
+ else {
306
+ storage.accounts.push({ id: accountID({ refreshToken: parts.refreshToken }), refreshToken: parts.refreshToken, projectId: parts.projectId, managedProjectId: parts.managedProjectId, email, enabled: true, addedAt: Date.now(), lastUsed: 0 });
307
+ }
308
+ });
309
+ return saved?.accounts.find(item => item.refreshToken === parts.refreshToken);
145
310
  };
146
311
  const getLoader = async () => {
147
312
  if (disposed)
@@ -161,21 +326,46 @@ export const AntigravityV2Plugin = Plugin.define({
161
326
  // Finish an older manager's pending save before selecting a new
162
327
  // connection, so it cannot overwrite the newly selected cursors.
163
328
  await transport.flush?.();
329
+ if (generation !== expectedGeneration)
330
+ return undefined;
164
331
  const token = parseRefreshParts(auth.refresh).refreshToken;
165
332
  let stored = await loadAccounts();
333
+ if (generation !== expectedGeneration)
334
+ return undefined;
166
335
  if (!stored?.accounts.some((account) => account.refreshToken === token)) {
336
+ // A resolved pool member can disappear or rotate during I/O. Only a
337
+ // previously unknown native credential may add a new pool member.
338
+ if (resolved.accountID)
339
+ return undefined;
340
+ const latest = await resolveAuth();
341
+ if (generation !== expectedGeneration || latest?.identity !== identity)
342
+ return undefined;
167
343
  await rememberAuth(auth);
168
- stored = await loadAccounts();
344
+ return undefined;
169
345
  }
170
346
  if (resolved.select && stored) {
171
347
  const index = stored.accounts.findIndex((account) => account.refreshToken === token && account.enabled !== false);
172
348
  if (index >= 0) {
173
349
  stored.activeIndex = index;
174
350
  stored.activeIndexByFamily = { claude: index, gemini: index };
175
- await saveAccounts(stored);
351
+ await updateAccounts(latest => {
352
+ const selected = latest.accounts.findIndex(account => account.refreshToken === token && account.enabled !== false);
353
+ if (selected >= 0) {
354
+ latest.activeIndex = selected;
355
+ latest.activeIndexByFamily = { claude: selected, gemini: selected };
356
+ }
357
+ });
176
358
  }
177
359
  }
178
- const result = await transport.auth.loader(async () => await currentAuth() ?? { type: "none" }, {});
360
+ let result;
361
+ try {
362
+ result = await transport.auth.loader(async () => await currentAuth() ?? { type: "none" }, {});
363
+ }
364
+ catch (error) {
365
+ if (!disposed && generation !== expectedGeneration)
366
+ return undefined;
367
+ throw error;
368
+ }
179
369
  if (typeof result.fetch !== "function")
180
370
  throw new Error("Antigravity OAuth is unavailable");
181
371
  if (disposed)
@@ -195,31 +385,39 @@ export const AntigravityV2Plugin = Plugin.define({
195
385
  }
196
386
  return result ?? getLoader();
197
387
  };
198
- const scopedFetch = (fallback, body) => async (input, init) => {
388
+ const scopedFetch = (fallback, body) => (input, init) => runCredentialOperation(async () => {
199
389
  if (disposed)
200
390
  throw new Error("Antigravity plugin has been disposed");
201
391
  const original = typeof fallback === "function" ? fallback : globalThis.fetch;
202
392
  if (!await currentAuth())
203
- return original(input, init);
393
+ throw new Error("Connect Google with Antigravity OAuth and enable an account to use this model");
204
394
  if (body && typeof init?.body === "string") {
205
395
  init = { ...init, body: JSON.stringify(mergeBody(JSON.parse(init.body), body)) };
206
396
  }
207
397
  const interceptor = await getLoader();
208
398
  const signal = AbortSignal.any([
209
399
  lifetime.signal,
400
+ requestLifetime.signal,
210
401
  ...(init?.signal ? [init.signal] : input instanceof Request ? [input.signal] : []),
211
402
  ]);
212
403
  return outbound.run(original, () => interceptor.fetch(input instanceof URL ? input.toString() : input, { ...init, signal }));
213
- };
214
- const cleanup = async () => {
215
- if (disposed)
216
- return;
404
+ });
405
+ let cleaning;
406
+ const cleanup = () => {
407
+ if (cleaning)
408
+ return cleaning;
217
409
  disposed = true;
218
- lifetime.abort(new Error("Antigravity plugin disposed"));
219
- await Promise.allSettled([...listeners].map((listener) => listener.close()));
220
- await Promise.allSettled(registrations.reverse().map((registration) => registration.dispose()));
221
- await transport.dispose?.();
222
- loader = undefined;
410
+ cleaning = Promise.resolve().then(async () => {
411
+ lifetime.abort(new Error("Antigravity plugin disposed"));
412
+ await Promise.allSettled([...listeners].map((listener) => listener.close()));
413
+ await Promise.allSettled([...credentialOperations]);
414
+ await Promise.allSettled([...discoveries]);
415
+ await Promise.allSettled([...catalogWrites]);
416
+ await Promise.allSettled(registrations.reverse().map((registration) => registration.dispose()));
417
+ await transport.dispose?.();
418
+ loader = undefined;
419
+ });
420
+ return cleaning;
223
421
  };
224
422
  try {
225
423
  registrations.push(await ctx.integration.transform((editor) => {
@@ -235,72 +433,98 @@ export const AntigravityV2Plugin = Plugin.define({
235
433
  ],
236
434
  },
237
435
  async authorize(answer) {
238
- const auth = await authorizeAntigravity(typeof answer.projectId === "string" ? answer.projectId : "");
239
- const state = new URL(auth.url).searchParams.get("state");
240
- if (!state)
241
- throw new Error("Antigravity authorization did not provide OAuth state");
242
- const complete = async (input) => {
243
- let code = input.trim();
244
- if (/^https?:\/\//i.test(code)) {
245
- const callback = new URL(code);
246
- const redirect = new URL(ANTIGRAVITY_REDIRECT_URI);
247
- if (callback.origin !== redirect.origin || callback.pathname !== redirect.pathname || callback.searchParams.get("state") !== state) {
248
- throw new Error("Invalid Antigravity OAuth callback URL or state");
436
+ return runCredentialOperation(async () => {
437
+ const auth = await authorizeAntigravity(typeof answer.projectId === "string" ? answer.projectId : "");
438
+ if (disposed)
439
+ throw new Error("Antigravity plugin has been disposed");
440
+ const state = new URL(auth.url).searchParams.get("state");
441
+ if (!state)
442
+ throw new Error("Antigravity authorization did not provide OAuth state");
443
+ const complete = (input) => runCredentialOperation(async () => {
444
+ let code = input.trim();
445
+ if (/^https?:\/\//i.test(code)) {
446
+ const callback = new URL(code);
447
+ const redirect = new URL(ANTIGRAVITY_REDIRECT_URI);
448
+ if (callback.origin !== redirect.origin || callback.pathname !== redirect.pathname || callback.searchParams.get("state") !== state) {
449
+ throw new Error("Invalid Antigravity OAuth callback URL or state");
450
+ }
451
+ code = callback.searchParams.get("code") ?? "";
249
452
  }
250
- code = callback.searchParams.get("code") ?? "";
453
+ if (!code)
454
+ throw new Error("Missing Antigravity authorization code");
455
+ const result = await exchangeAntigravity(code, state);
456
+ if (result.type !== "success")
457
+ throw new Error("Antigravity authorization failed; please try signing in again");
458
+ const account = await rememberAuth({ ...result, type: "oauth" }, result.email);
459
+ await updateAccounts(accounts => {
460
+ const current = accounts.accounts.find(item => item.id === account?.id);
461
+ if (!current)
462
+ return;
463
+ current.enabled = true;
464
+ current.verificationRequired = false;
465
+ delete current.verificationRequiredAt;
466
+ delete current.verificationRequiredReason;
467
+ delete current.verificationUrl;
468
+ });
469
+ await ctx.storage.remove("pool-selection");
470
+ invalidateLoader();
471
+ return oauthCredential({ ...result, type: "oauth" }, { ...(result.email ? { email: result.email } : {}), ...(account?.id ? { antigravityAccountId: account.id } : {}) });
472
+ });
473
+ if (answer.manual !== false) {
474
+ return { url: auth.url, instructions: "Open this URL, then paste the authorization code or full callback URL.", mode: "code", callback: complete };
475
+ }
476
+ const listener = await startOAuthListener();
477
+ listeners.add(listener);
478
+ const callback = listener.waitForCallback()
479
+ .then((url) => complete(url.toString()))
480
+ .finally(async () => {
481
+ listeners.delete(listener);
482
+ await listener.close();
483
+ });
484
+ // The host consumes this promise after returning the authorization.
485
+ void callback.catch(() => undefined);
486
+ if (disposed) {
487
+ await listener.close();
488
+ throw new Error("Antigravity plugin has been disposed");
251
489
  }
252
- if (!code)
253
- throw new Error("Missing Antigravity authorization code");
254
- const result = await exchangeAntigravity(code, state);
255
- if (result.type !== "success")
256
- throw new Error("Antigravity authorization failed; please try signing in again");
257
- await persistAccountPool([result]);
258
- invalidateLoader();
259
- return oauthCredential({ ...result, type: "oauth" }, result.email ? { email: result.email } : undefined);
260
- };
261
- if (answer.manual !== false) {
262
- return { url: auth.url, instructions: "Open this URL, then paste the authorization code or full callback URL.", mode: "code", callback: complete };
263
- }
264
- const listener = await startOAuthListener();
265
- listeners.add(listener);
266
- const callback = listener.waitForCallback()
267
- .then((url) => complete(url.toString()))
268
- .finally(async () => {
269
- listeners.delete(listener);
270
- await listener.close();
490
+ return { url: auth.url, instructions: "Open this URL in your browser to connect Antigravity.", mode: "auto", callback };
271
491
  });
272
- // The host consumes this promise after returning the authorization.
273
- void callback.catch(() => undefined);
274
- return { url: auth.url, instructions: "Open this URL in your browser to connect Antigravity.", mode: "auto", callback };
275
492
  },
276
493
  async refresh(credential) {
277
- try {
278
- const refreshed = await refreshAccessToken(credential, client, PROVIDER_ID);
279
- if (!refreshed?.access)
280
- throw new Error("Antigravity token refresh temporarily unavailable");
281
- const stored = await loadAccounts();
282
- const email = stored?.accounts.find((account) => account.refreshToken === parseRefreshParts(credential.refresh).refreshToken)?.email;
283
- await rememberAuth(refreshed, email);
284
- return oauthCredential(refreshed, credential.metadata);
285
- }
286
- catch (error) {
287
- if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") {
288
- const stored = await loadAccounts();
289
- const account = stored?.accounts.find((item) => item.refreshToken === parseRefreshParts(credential.refresh).refreshToken);
290
- if (account && stored) {
291
- account.enabled = false;
292
- await saveAccounts(stored);
494
+ return runCredentialOperation(async () => {
495
+ const sourceConnection = await ctx.integration.connection.active(PROVIDER_ID);
496
+ const stored = await loadManagedAccounts();
497
+ const original = stored?.accounts.find(account => matchesAccountToken(account, parseRefreshParts(credential.refresh).refreshToken) || (typeof credential.metadata?.antigravityAccountId === "string" && account.id === credential.metadata.antigravityAccountId));
498
+ const previous = original?.id ? { ...credential, refresh: formatRefreshParts(original), metadata: { ...credential.metadata, antigravityAccountId: original.id } } : credential;
499
+ try {
500
+ const refreshed = await refreshAccessToken(previous, client, PROVIDER_ID);
501
+ if (!refreshed?.access)
502
+ throw new Error("Antigravity token refresh temporarily unavailable");
503
+ const account = await rememberAuth(refreshed, original?.email, previous);
504
+ invalidateLoader(false);
505
+ return oauthCredential(account ? { ...refreshed, refresh: formatRefreshParts(account) } : refreshed, { ...credential.metadata, ...(account?.id ? { antigravityAccountId: account.id } : {}) });
506
+ }
507
+ catch (error) {
508
+ if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") {
509
+ let revoked = false;
510
+ await updateAccounts(stored => {
511
+ const account = stored.accounts.find(item => item.refreshToken === parseRefreshParts(previous.refresh).refreshToken);
512
+ if (account) {
513
+ account.enabled = false;
514
+ revoked = true;
515
+ }
516
+ });
517
+ const boundID = sourceConnection?.type === "credential" ? await ctx.storage.get(`connection-account:${sourceConnection.id}`) : undefined;
518
+ if (revoked && sourceConnection && typeof boundID === "string" && boundID === original?.id)
519
+ await ctx.integration.connection.status({ integrationID: PROVIDER_ID, connection: sourceConnection, status: { status: "needs_auth", message: "Google revoked this account; sign in again." } });
520
+ invalidateLoader();
293
521
  }
294
- const connection = await ctx.integration.connection.active(PROVIDER_ID);
295
- if (connection)
296
- await ctx.integration.connection.status({ integrationID: PROVIDER_ID, connection, status: { status: "needs_auth", message: "Google revoked this account; sign in again." } });
297
- invalidateLoader();
522
+ // Core resolves credentials before model construction. Keep that
523
+ // step available so the transport can rotate to another account.
524
+ // Six minutes clears Core's five-minute refresh buffer briefly.
525
+ return { ...credential, access: "", expires: Date.now() + 6 * 60_000 };
298
526
  }
299
- // Core resolves credentials before model construction. Keep that
300
- // step available so the transport can rotate to another account.
301
- // Six minutes clears Core's five-minute refresh buffer briefly.
302
- return { ...credential, access: "", expires: Date.now() + 6 * 60_000 };
303
- }
527
+ });
304
528
  },
305
529
  label: (credential) => typeof credential.metadata?.email === "string" ? credential.metadata.email : undefined,
306
530
  });
@@ -320,7 +544,13 @@ export const AntigravityV2Plugin = Plugin.define({
320
544
  provider.activation = "enabled";
321
545
  });
322
546
  }
323
- for (const [id, definition] of Object.entries(OPENCODE_V2_MODEL_DEFINITIONS)) {
547
+ if (accountCatalogLoaded) {
548
+ for (const id of editor.get(PROVIDER_ID)?.models.keys() ?? []) {
549
+ if (ownsModel(id) && !modelDefinitions[id])
550
+ editor.models.remove(PROVIDER_ID, id);
551
+ }
552
+ }
553
+ for (const [id, definition] of Object.entries(modelDefinitions)) {
324
554
  editor.models.update(PROVIDER_ID, id, (model) => {
325
555
  Object.assign(model, structuredClone(definition), { package: GOOGLE_PACKAGE });
326
556
  model.variants = structuredClone(definition.variants).map((variant) => ({ ...variant, id: Model.VariantID.make(variant.id) }));
@@ -332,6 +562,15 @@ export const AntigravityV2Plugin = Plugin.define({
332
562
  const id = String(model.id);
333
563
  if (!ownsModel(id))
334
564
  continue;
565
+ if (accountCatalogLoaded && !modelDefinitions[id]) {
566
+ // Configured models are raw overrides. Removing an override can
567
+ // reveal its native Google route; disable it explicitly instead.
568
+ editor.update(PROVIDER_ID, id, target => {
569
+ target.enabled = false;
570
+ target.package = GOOGLE_PACKAGE;
571
+ });
572
+ continue;
573
+ }
335
574
  editor.update(PROVIDER_ID, id, (target) => {
336
575
  // A native provider override can otherwise bypass the SDK hook.
337
576
  target.package = GOOGLE_PACKAGE;
@@ -339,7 +578,12 @@ export const AntigravityV2Plugin = Plugin.define({
339
578
  }
340
579
  }));
341
580
  registrations.push(await ctx.aisdk.hook("sdk", async (event) => {
342
- if (event.package !== SDK_PACKAGE || !ownsModel(event.model.id))
581
+ if (!ownsModel(event.model.id))
582
+ return;
583
+ if (accountCatalogLoaded && !modelDefinitions[event.model.id]) {
584
+ throw new Error("This model is unavailable to the enabled Antigravity accounts. Refresh available models from /antigravity.");
585
+ }
586
+ if (event.package !== SDK_PACKAGE)
343
587
  return;
344
588
  event.sdk = createGoogleGenerativeAI({
345
589
  ...event.options,
@@ -365,21 +609,58 @@ export const AntigravityV2Plugin = Plugin.define({
365
609
  additionalProperties: false,
366
610
  },
367
611
  async execute(input, context) {
368
- const args = input;
369
- if (!args || typeof args.query !== "string" || !args.query.trim())
370
- throw new Error("A search query is required");
371
- let auth = await currentAuth();
372
- if (!auth)
373
- return { content: "Connect Google with Antigravity OAuth to use Google Search." };
374
- if (!auth.access || accessTokenExpired(auth))
375
- auth = await refreshAccessToken(auth, client, PROVIDER_ID);
376
- if (!auth?.access)
377
- throw new Error("Antigravity token refresh failed; please sign in again");
378
- const project = await ensureProjectContext(auth);
379
- return { content: await executeSearch(args, auth.access, project.effectiveProjectId, AbortSignal.any([context.signal, lifetime.signal])) };
612
+ return runCredentialOperation(async () => {
613
+ const signal = AbortSignal.any([context.signal, lifetime.signal, requestLifetime.signal]);
614
+ signal.throwIfAborted();
615
+ const args = input;
616
+ if (!args || typeof args.query !== "string" || !args.query.trim())
617
+ throw new Error("A search query is required");
618
+ const resolved = await resolveAuth();
619
+ let auth = resolved?.auth;
620
+ if (!auth)
621
+ return { content: "Connect Google with Antigravity OAuth to use Google Search." };
622
+ const metadata = resolved?.accountID ? { antigravityAccountId: resolved.accountID } : undefined;
623
+ if (!auth.access || accessTokenExpired(auth)) {
624
+ const previous = oauthCredential(auth, metadata);
625
+ auth = await refreshAccessToken(auth, client, PROVIDER_ID);
626
+ if (auth)
627
+ await rememberAuth(auth, undefined, previous);
628
+ }
629
+ if (!auth?.access)
630
+ throw new Error("Antigravity token refresh failed; please sign in again");
631
+ signal.throwIfAborted();
632
+ const project = await ensureProjectContext(auth);
633
+ if (project.auth.refresh !== auth.refresh)
634
+ await rememberAuth(project.auth, undefined, oauthCredential(auth, metadata));
635
+ signal.throwIfAborted();
636
+ return { content: await executeSearch(args, auth.access, project.effectiveProjectId, signal) };
637
+ });
380
638
  },
381
639
  });
382
640
  }));
641
+ registrations.push(await registerAccountManagement(ctx, client, async () => {
642
+ invalidateLoader();
643
+ await transport.flush?.();
644
+ }, refreshModels, async () => {
645
+ hasSavedAccounts = (await loadAccounts())?.accounts.some(account => account.enabled !== false);
646
+ if (hasSavedAccounts && ctx.options.discoverModels !== false) {
647
+ try {
648
+ await refreshModels();
649
+ }
650
+ catch (error) {
651
+ log.warn("Model refresh unavailable", { error: String(error) });
652
+ }
653
+ }
654
+ await ctx.provider.reload();
655
+ }, runCredentialOperation));
656
+ if (hasSavedAccounts && ctx.options.discoverModels !== false && (!validCache || Date.now() - (cached?.updatedAt ?? 0) > 3600_000)) {
657
+ try {
658
+ await refreshModels();
659
+ }
660
+ catch (error) {
661
+ log.warn("Model discovery unavailable; using cached or fallback models", { error: String(error) });
662
+ }
663
+ }
383
664
  return cleanup;
384
665
  }
385
666
  catch (error) {