@cortexkit/antigravity-auth-core 1.1.0 → 2.0.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 (105) hide show
  1. package/dist/account-manager.d.ts +325 -0
  2. package/dist/account-manager.d.ts.map +1 -0
  3. package/dist/account-manager.js +1480 -0
  4. package/dist/account-manager.js.map +1 -0
  5. package/dist/account-storage.d.ts +162 -0
  6. package/dist/account-storage.d.ts.map +1 -0
  7. package/dist/account-storage.js +635 -0
  8. package/dist/account-storage.js.map +1 -0
  9. package/dist/account-types.d.ts +155 -0
  10. package/dist/account-types.d.ts.map +1 -0
  11. package/dist/account-types.js +9 -0
  12. package/dist/account-types.js.map +1 -0
  13. package/dist/agy-request-metadata.d.ts +12 -5
  14. package/dist/agy-request-metadata.d.ts.map +1 -1
  15. package/dist/agy-request-metadata.js +64 -38
  16. package/dist/agy-request-metadata.js.map +1 -1
  17. package/dist/agy-transport.d.ts +2 -2
  18. package/dist/agy-transport.d.ts.map +1 -1
  19. package/dist/agy-transport.js +87 -74
  20. package/dist/agy-transport.js.map +1 -1
  21. package/dist/antigravity/oauth.d.ts +3 -2
  22. package/dist/antigravity/oauth.d.ts.map +1 -1
  23. package/dist/antigravity/oauth.js +65 -55
  24. package/dist/antigravity/oauth.js.map +1 -1
  25. package/dist/atomic-write.d.ts +23 -0
  26. package/dist/atomic-write.d.ts.map +1 -0
  27. package/dist/atomic-write.js +47 -0
  28. package/dist/atomic-write.js.map +1 -0
  29. package/dist/auth-types.d.ts +12 -2
  30. package/dist/auth-types.d.ts.map +1 -1
  31. package/dist/auth.d.ts +1 -1
  32. package/dist/auth.d.ts.map +1 -1
  33. package/dist/auth.js +9 -6
  34. package/dist/auth.js.map +1 -1
  35. package/dist/constants.d.ts +19 -12
  36. package/dist/constants.d.ts.map +1 -1
  37. package/dist/constants.js +51 -39
  38. package/dist/constants.js.map +1 -1
  39. package/dist/fetch-timeout.d.ts +26 -0
  40. package/dist/fetch-timeout.d.ts.map +1 -0
  41. package/dist/fetch-timeout.js +43 -0
  42. package/dist/fetch-timeout.js.map +1 -0
  43. package/dist/file-lock.d.ts +79 -0
  44. package/dist/file-lock.d.ts.map +1 -0
  45. package/dist/file-lock.js +438 -0
  46. package/dist/file-lock.js.map +1 -0
  47. package/dist/fingerprint.d.ts +9 -3
  48. package/dist/fingerprint.d.ts.map +1 -1
  49. package/dist/fingerprint.js +28 -20
  50. package/dist/fingerprint.js.map +1 -1
  51. package/dist/index.d.ts +23 -13
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +18 -9
  54. package/dist/index.js.map +1 -1
  55. package/dist/logger.d.ts +1 -1
  56. package/dist/logger.d.ts.map +1 -1
  57. package/dist/logger.js +10 -10
  58. package/dist/logger.js.map +1 -1
  59. package/dist/model-registry.d.ts +5 -5
  60. package/dist/model-registry.d.ts.map +1 -1
  61. package/dist/model-registry.js +119 -107
  62. package/dist/model-registry.js.map +1 -1
  63. package/dist/project.d.ts +4 -1
  64. package/dist/project.d.ts.map +1 -1
  65. package/dist/project.js +84 -31
  66. package/dist/project.js.map +1 -1
  67. package/dist/quota-manager.d.ts +203 -0
  68. package/dist/quota-manager.d.ts.map +1 -0
  69. package/dist/quota-manager.js +718 -0
  70. package/dist/quota-manager.js.map +1 -0
  71. package/dist/quota-types.d.ts +68 -0
  72. package/dist/quota-types.d.ts.map +1 -0
  73. package/dist/quota-types.js +50 -0
  74. package/dist/quota-types.js.map +1 -0
  75. package/dist/rotation.d.ts +175 -0
  76. package/dist/rotation.d.ts.map +1 -0
  77. package/dist/rotation.js +391 -0
  78. package/dist/rotation.js.map +1 -0
  79. package/dist/transform/claude.d.ts +1 -1
  80. package/dist/transform/claude.d.ts.map +1 -1
  81. package/dist/transform/claude.js +63 -41
  82. package/dist/transform/claude.js.map +1 -1
  83. package/dist/transform/cross-model-sanitizer.d.ts +1 -1
  84. package/dist/transform/cross-model-sanitizer.d.ts.map +1 -1
  85. package/dist/transform/cross-model-sanitizer.js +19 -16
  86. package/dist/transform/cross-model-sanitizer.js.map +1 -1
  87. package/dist/transform/gemini.d.ts +1 -1
  88. package/dist/transform/gemini.d.ts.map +1 -1
  89. package/dist/transform/gemini.js +111 -85
  90. package/dist/transform/gemini.js.map +1 -1
  91. package/dist/transform/index.d.ts +9 -9
  92. package/dist/transform/index.d.ts.map +1 -1
  93. package/dist/transform/index.js +6 -6
  94. package/dist/transform/index.js.map +1 -1
  95. package/dist/transform/model-resolver.d.ts +5 -5
  96. package/dist/transform/model-resolver.d.ts.map +1 -1
  97. package/dist/transform/model-resolver.js +97 -75
  98. package/dist/transform/model-resolver.js.map +1 -1
  99. package/dist/transform/types.d.ts +5 -5
  100. package/dist/transform/types.d.ts.map +1 -1
  101. package/dist/version.d.ts +1 -1
  102. package/dist/version.d.ts.map +1 -1
  103. package/dist/version.js +14 -16
  104. package/dist/version.js.map +1 -1
  105. package/package.json +16 -10
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Harness-agnostic attributed quota manager.
3
+ *
4
+ * Owns the per-account quota cache, in-flight de-duplication, and exponential
5
+ * backoff that backgrounds proactive quota refreshes. The manager has no host
6
+ * dependencies — harnesses supply a `fetchAccountQuota` callback that returns
7
+ * the already-attributed `AccountQuotaResult` (with `index`, `email`,
8
+ * `updatedAccount`, etc.) so core stays harness-agnostic.
9
+ *
10
+ * Manual quota dialogs must force a refresh even when background refresh is
11
+ * backed off; proactive refreshes must dedupe by stable account identity and
12
+ * respect backoff. Account identity is supplied via `keyOf` so reorders/
13
+ * removals of the underlying array do not invalidate cache entries.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { fetchWithActiveTimeout } from "./fetch-timeout.js";
17
+ import { buildAntigravityHarnessUserAgent } from "./fingerprint.js";
18
+ import { createLogger } from "./logger.js";
19
+ import { getQuotaGroupForModel } from "./model-registry.js";
20
+ const log = createLogger('quota-manager');
21
+ export const QUOTA_MANAGER_DEFAULT_BASE_BACKOFF_MS = 30_000;
22
+ export const QUOTA_MANAGER_DEFAULT_MAX_BACKOFF_MS = 10 * 60 * 1000;
23
+ export const QUOTA_MANAGER_DEFAULT_TIMEOUT_MS = 10_000;
24
+ /**
25
+ * Default keyOf — prefers email, falls back to refresh-token hash so the
26
+ * same identity is keyed even when emails are missing.
27
+ */
28
+ export function defaultKeyOf(account) {
29
+ if (account.email)
30
+ return `e:${account.email.toLowerCase()}`;
31
+ const token = account.refreshToken || '';
32
+ return `t:${createHash('sha256').update(token).digest('hex').slice(0, 16)}`;
33
+ }
34
+ export function createQuotaManager(options) {
35
+ const now = options.now ?? (() => Date.now());
36
+ const baseBackoffMs = options.baseBackoffMs ?? QUOTA_MANAGER_DEFAULT_BASE_BACKOFF_MS;
37
+ const maxBackoffMs = options.maxBackoffMs ?? QUOTA_MANAGER_DEFAULT_MAX_BACKOFF_MS;
38
+ const fetchTimeoutMs = options.fetchTimeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS;
39
+ const state = new Map();
40
+ let disposed = false;
41
+ const keyOf = (account) => options.keyOf(account);
42
+ const recordSuccess = (key) => {
43
+ const entry = state.get(key);
44
+ if (!entry)
45
+ return;
46
+ entry.consecutiveFailures = 0;
47
+ entry.backoffUntil = 0;
48
+ };
49
+ const recordFailure = (key) => {
50
+ const current = now();
51
+ const entry = state.get(key) ?? {
52
+ consecutiveFailures: 0,
53
+ backoffUntil: 0,
54
+ };
55
+ const failures = entry.consecutiveFailures + 1;
56
+ const backoffMs = Math.min(maxBackoffMs, baseBackoffMs * 2 ** (failures - 1));
57
+ entry.consecutiveFailures = failures;
58
+ entry.backoffUntil = current + backoffMs;
59
+ state.set(key, entry);
60
+ return { backoffMs, backoffUntil: entry.backoffUntil };
61
+ };
62
+ const cacheResult = (key, result) => {
63
+ const entry = state.get(key) ?? {
64
+ consecutiveFailures: 0,
65
+ backoffUntil: 0,
66
+ };
67
+ entry.cached = result;
68
+ state.set(key, entry);
69
+ };
70
+ const buildDisabledResult = (account, index) => ({
71
+ index,
72
+ email: account.email,
73
+ status: 'disabled',
74
+ disabled: true,
75
+ quota: undefined,
76
+ geminiCliQuota: undefined,
77
+ updatedAccount: undefined,
78
+ });
79
+ const buildSkippedResult = (account, index, backoffUntil) => {
80
+ const cached = state.get(keyOf(account))?.cached;
81
+ if (cached) {
82
+ return { ...cached, index };
83
+ }
84
+ return {
85
+ index,
86
+ email: account.email,
87
+ status: 'error',
88
+ error: `quota refresh skipped (backoff until ${new Date(backoffUntil).toISOString()})`,
89
+ };
90
+ };
91
+ const refreshAccount = async (account, refreshOptions) => {
92
+ if (disposed) {
93
+ return {
94
+ index: refreshOptions.index,
95
+ email: account.email,
96
+ status: 'error',
97
+ error: 'quota manager disposed',
98
+ };
99
+ }
100
+ const { index, force = false } = refreshOptions;
101
+ const key = keyOf(account);
102
+ if (account.enabled === false) {
103
+ const result = buildDisabledResult(account, index);
104
+ cacheResult(key, result);
105
+ return result;
106
+ }
107
+ const current = now();
108
+ const entry = state.get(key);
109
+ if (!force && entry && entry.backoffUntil > current) {
110
+ return buildSkippedResult(account, index, entry.backoffUntil);
111
+ }
112
+ const inflight = entry?.inflight;
113
+ if (inflight) {
114
+ // Reuse the cached result of the in-flight fetch, but preserve this
115
+ // caller's requested index for attribution.
116
+ const cached = await inflight;
117
+ return { ...cached, index };
118
+ }
119
+ const controller = new AbortController();
120
+ const stored = state.get(key) ?? {
121
+ consecutiveFailures: 0,
122
+ backoffUntil: 0,
123
+ };
124
+ stored.controller = controller;
125
+ state.set(key, stored);
126
+ const promise = (async () => {
127
+ try {
128
+ const signal = AbortSignal.timeout(fetchTimeoutMs);
129
+ const composite = controller.signal.aborted
130
+ ? controller.signal
131
+ : AbortSignal.any([controller.signal, signal]);
132
+ const result = await options.fetchAccountQuota(account, composite);
133
+ if (controller.signal.aborted) {
134
+ throw new Error('quota manager disposed mid-fetch');
135
+ }
136
+ const attributed = { ...result, index };
137
+ cacheResult(key, attributed);
138
+ // Adapter contract: `fetchAccountQuota` resolves with an
139
+ // attributed result. Failures should arrive as `{ status: 'error' }`
140
+ // rather than throwing — treat them like thrown failures so backoff
141
+ // actually protects the account from re-hammering. `disabled` and
142
+ // `ok` both count as success (no fetch happened, or it succeeded).
143
+ if (result.status === 'error') {
144
+ const { backoffMs } = recordFailure(key);
145
+ log.debug('quota-refresh-failed', {
146
+ key: hashKey(key),
147
+ backoffMs,
148
+ error: result.error ?? 'attributed error result',
149
+ });
150
+ return attributed;
151
+ }
152
+ recordSuccess(key);
153
+ return attributed;
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ if (disposed || controller.signal.aborted) {
158
+ const failureResult = {
159
+ index,
160
+ email: account.email,
161
+ status: 'error',
162
+ error: message,
163
+ };
164
+ cacheResult(key, failureResult);
165
+ return failureResult;
166
+ }
167
+ const { backoffMs } = recordFailure(key);
168
+ log.debug('quota-refresh-failed', {
169
+ key: hashKey(key),
170
+ backoffMs,
171
+ error: message,
172
+ });
173
+ const failureResult = {
174
+ index,
175
+ email: account.email,
176
+ status: 'error',
177
+ error: message,
178
+ };
179
+ cacheResult(key, failureResult);
180
+ return failureResult;
181
+ }
182
+ finally {
183
+ const finished = state.get(key);
184
+ if (finished) {
185
+ finished.inflight = undefined;
186
+ finished.controller = undefined;
187
+ state.set(key, finished);
188
+ }
189
+ }
190
+ })();
191
+ stored.inflight = promise;
192
+ state.set(key, stored);
193
+ return promise;
194
+ };
195
+ const refreshAccounts = async (accounts, refreshOptions) => {
196
+ const results = [];
197
+ for (const account of accounts) {
198
+ const index = refreshOptions.indexFor(account);
199
+ const result = await refreshAccount(account, {
200
+ index,
201
+ force: refreshOptions.force,
202
+ });
203
+ results.push(result);
204
+ }
205
+ return results;
206
+ };
207
+ const getCached = (account) => {
208
+ const entry = state.get(keyOf(account));
209
+ return entry?.cached;
210
+ };
211
+ const getBackoffUntil = (account) => {
212
+ return state.get(keyOf(account))?.backoffUntil ?? 0;
213
+ };
214
+ const hashedLogLabel = (prefix, account) => {
215
+ const identity = typeof account === 'string' ? account : keyOf(account);
216
+ return `${prefix} ${hashKey(identity)}`;
217
+ };
218
+ const dispose = async () => {
219
+ if (disposed)
220
+ return;
221
+ disposed = true;
222
+ // Snapshot the in-flight refreshes before aborting — the refresh
223
+ // `finally` clears `entry.inflight` as each promise settles, so we
224
+ // must capture the promises first.
225
+ const pending = Array.from(state.values(), (entry) => entry.inflight).filter((promise) => promise != null);
226
+ // Abort so a genuinely-stuck fetch unwinds promptly (the refresh
227
+ // catches the abort and resolves with an error result rather than
228
+ // hanging dispose), then AWAIT the settled promises. Awaiting after
229
+ // the abort still fences the producer: the opencode quota wrapper's
230
+ // fire-and-forget sidebar write runs in the continuation after the
231
+ // core refresh resolves, so by the time dispose() resolves that
232
+ // write has already been enqueued onto the sidebar chain and the
233
+ // subsequent lifecycle drain will flush it.
234
+ for (const [, entry] of state) {
235
+ entry.controller?.abort();
236
+ }
237
+ if (pending.length > 0) {
238
+ // Each refresh swallows its own errors and resolves; awaiting is a
239
+ // fence, never a rejection surface.
240
+ await Promise.allSettled(pending);
241
+ }
242
+ for (const [, entry] of state) {
243
+ entry.inflight = undefined;
244
+ }
245
+ };
246
+ return {
247
+ refreshAccount,
248
+ refreshAccounts,
249
+ getCached,
250
+ getBackoffUntil,
251
+ hashedLogLabel,
252
+ dispose,
253
+ classifyQuotaGroup,
254
+ aggregateQuota,
255
+ aggregateGeminiCliQuota,
256
+ };
257
+ }
258
+ function hashKey(key) {
259
+ return createHash('sha256').update(key).digest('hex').slice(0, 8);
260
+ }
261
+ // ============================================================================
262
+ // Pure helpers — also exported for adapter use.
263
+ // ============================================================================
264
+ /**
265
+ * Classify a model into its quota group.
266
+ */
267
+ export function classifyQuotaGroup(modelName, displayName) {
268
+ const registryGroup = getQuotaGroupForModel(modelName);
269
+ if (registryGroup) {
270
+ return registryGroup;
271
+ }
272
+ const combined = `${modelName} ${displayName ?? ''}`.toLowerCase();
273
+ // Check Claude / GPT-OSS substrings BEFORE the `gemini` substring so a
274
+ // `gemini-claude-*` alias (Claude route exposed under a `gemini-`
275
+ // namespace) attributes to the non-gemini pool rather than the gemini
276
+ // pool. `tab_*` autocomplete IDs are already classified by
277
+ // `getQuotaGroupForModel` above (the registry/prefix branches), so
278
+ // this fallback only runs for genuinely-unrecognised model strings.
279
+ if (combined.includes('claude') || combined.includes('gpt-oss')) {
280
+ return 'non-gemini';
281
+ }
282
+ if (combined.includes('gemini')) {
283
+ return 'gemini';
284
+ }
285
+ return null;
286
+ }
287
+ function normalizeRemainingFraction(value) {
288
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
289
+ return 0;
290
+ }
291
+ if (value < 0)
292
+ return 0;
293
+ if (value > 1)
294
+ return 1;
295
+ return value;
296
+ }
297
+ function parseResetTime(resetTime) {
298
+ if (!resetTime)
299
+ return null;
300
+ const timestamp = Date.parse(resetTime);
301
+ if (!Number.isFinite(timestamp)) {
302
+ return null;
303
+ }
304
+ return timestamp;
305
+ }
306
+ /**
307
+ * Aggregate per-model quota entries into group summaries + per-model list.
308
+ *
309
+ * Pure helper — exposed for harness adapters that want to reuse the same
310
+ * aggregation logic without re-implementing it.
311
+ */
312
+ export function aggregateQuota(models) {
313
+ const groups = {};
314
+ const perModel = [];
315
+ if (!models) {
316
+ return { groups, perModel, modelCount: 0 };
317
+ }
318
+ let totalCount = 0;
319
+ for (const [modelName, entry] of Object.entries(models)) {
320
+ const group = classifyQuotaGroup(modelName, entry.displayName ?? entry.modelName);
321
+ const quotaInfo = entry.quotaInfo;
322
+ const remainingFraction = quotaInfo
323
+ ? normalizeRemainingFraction(quotaInfo.remainingFraction)
324
+ : undefined;
325
+ const resetTime = quotaInfo?.resetTime;
326
+ const resetTimestamp = parseResetTime(resetTime);
327
+ totalCount += 1;
328
+ perModel.push({
329
+ modelId: modelName,
330
+ displayName: entry.displayName ?? entry.modelName,
331
+ group,
332
+ remainingFraction: remainingFraction ?? 0,
333
+ resetTime,
334
+ });
335
+ if (!group) {
336
+ continue;
337
+ }
338
+ const existing = groups[group];
339
+ const nextCount = (existing?.modelCount ?? 0) + 1;
340
+ const nextRemaining = remainingFraction === undefined
341
+ ? existing?.remainingFraction
342
+ : existing?.remainingFraction === undefined
343
+ ? remainingFraction
344
+ : Math.min(existing.remainingFraction, remainingFraction);
345
+ let nextResetTime = existing?.resetTime;
346
+ if (resetTimestamp !== null) {
347
+ if (!existing?.resetTime) {
348
+ nextResetTime = resetTime;
349
+ }
350
+ else {
351
+ const existingTimestamp = parseResetTime(existing.resetTime);
352
+ if (existingTimestamp === null || resetTimestamp < existingTimestamp) {
353
+ nextResetTime = resetTime;
354
+ }
355
+ }
356
+ }
357
+ groups[group] = {
358
+ remainingFraction: nextRemaining,
359
+ resetTime: nextResetTime,
360
+ modelCount: nextCount,
361
+ };
362
+ }
363
+ perModel.sort((a, b) => a.modelId.localeCompare(b.modelId));
364
+ return { groups, perModel, modelCount: totalCount };
365
+ }
366
+ /**
367
+ * Derive the most-constrained window from a set of window entries.
368
+ * Returns the entry with the smallest `remainingFraction` — this is the
369
+ * binding constraint for the pool. `resetTime` comes from the same window.
370
+ * Returns `undefined` when there are no windows.
371
+ */
372
+ function mostConstrainedWindow(windows) {
373
+ if (windows.length === 0)
374
+ return undefined;
375
+ let best = windows[0];
376
+ for (let i = 1; i < windows.length; i++) {
377
+ if (windows[i].remainingFraction < best.remainingFraction) {
378
+ best = windows[i];
379
+ }
380
+ }
381
+ return {
382
+ remainingFraction: best.remainingFraction,
383
+ resetTime: best.resetTime,
384
+ };
385
+ }
386
+ /**
387
+ * Map a retrieveUserQuotaSummary bucketId prefix to our internal pool.
388
+ *
389
+ * bucketId prefixes:
390
+ * `gemini-*` → gemini
391
+ * `3p-*` → non-gemini
392
+ */
393
+ function poolForBucketId(bucketId) {
394
+ if (bucketId.startsWith('gemini-'))
395
+ return 'gemini';
396
+ if (bucketId.startsWith('3p-'))
397
+ return 'non-gemini';
398
+ return null;
399
+ }
400
+ /**
401
+ * Count models listed in a group's description. The description is
402
+ * typically a comma-separated list of model names ("Claude Sonnet 4.6,
403
+ * Gemini 3.1 Pro, Flash") often prefixed with a label like
404
+ * "Models within this group:". Strip the prefix before splitting so
405
+ * the label itself is not counted as a model. Returns 0 for shapes
406
+ * that don't match a comma-separated list.
407
+ */
408
+ function parseDescriptionModelCount(description) {
409
+ const prefixMatch = description.match(/^[^:]+:\s*/);
410
+ const payload = prefixMatch
411
+ ? description.slice(prefixMatch[0].length)
412
+ : description;
413
+ if (!payload)
414
+ return 0;
415
+ const entries = payload
416
+ .split(',')
417
+ .map((entry) => entry.trim())
418
+ .filter((entry) => entry.length > 0);
419
+ return entries.length;
420
+ }
421
+ /**
422
+ * Map a quota window kind to its duration in milliseconds for
423
+ * deterministic sort order. Unknown windows get MAX_SAFE_INTEGER so
424
+ * they sort last without random reordering across runs.
425
+ */
426
+ function windowDurationMs(window) {
427
+ switch (window) {
428
+ case '5h':
429
+ return 5 * 60 * 60 * 1000;
430
+ case 'weekly':
431
+ return 7 * 24 * 60 * 60 * 1000;
432
+ default:
433
+ return Number.MAX_SAFE_INTEGER;
434
+ }
435
+ }
436
+ /**
437
+ * Aggregate a retrieveUserQuotaSummary response into a QuotaSummary.
438
+ *
439
+ * Each RUQS group maps to a pool via bucketId prefix. Within a pool,
440
+ * windows are stored shortest-first (5h before weekly, etc.). The pool's
441
+ * `remainingFraction`/`resetTime` derive from the most-constrained window.
442
+ */
443
+ export function aggregateQuotaSummary(response) {
444
+ const groups = {};
445
+ let totalCount = 0;
446
+ for (const group of response.groups) {
447
+ const windows = [];
448
+ for (const bucket of group.buckets) {
449
+ const pool = poolForBucketId(bucket.bucketId);
450
+ if (!pool)
451
+ continue;
452
+ windows.push({
453
+ window: bucket.window,
454
+ remainingFraction: normalizeRemainingFraction(bucket.remainingFraction),
455
+ resetTime: bucket.resetTime,
456
+ });
457
+ }
458
+ if (windows.length === 0)
459
+ continue;
460
+ // Order: shortest window first so the binding 5h window leads visually.
461
+ windows.sort((a, b) => windowDurationMs(a.window) - windowDurationMs(b.window));
462
+ const constrained = mostConstrainedWindow(windows);
463
+ // Pick the first RECOGNIZED bucket for pool derivation. Older
464
+ // servers may prepend a junk bucket (system noise, a non-standard
465
+ // prefix like `claude-3p-`) whose bucketId doesn't match any of
466
+ // our pool prefixes; the legacy poolForBucketId derivation used
467
+ // group.buckets[0] unconditionally, which silently dropped the
468
+ // whole group when an unknown prefix led the array.
469
+ const recognizedBucket = group.buckets.find((bucket) => poolForBucketId(bucket.bucketId));
470
+ if (!recognizedBucket)
471
+ continue;
472
+ const pool = poolForBucketId(recognizedBucket.bucketId);
473
+ if (!pool || !constrained)
474
+ continue;
475
+ // The description is a list of model names comma-separated, often
476
+ // prefixed with a label like "Models within this group:". Strip
477
+ // the prefix before splitting so it doesn't count as a model.
478
+ const modelCount = group.description
479
+ ? parseDescriptionModelCount(group.description)
480
+ : 0;
481
+ groups[pool] = {
482
+ remainingFraction: constrained.remainingFraction,
483
+ resetTime: constrained.resetTime,
484
+ modelCount,
485
+ windows,
486
+ };
487
+ totalCount += modelCount;
488
+ }
489
+ return { groups, modelCount: totalCount };
490
+ }
491
+ /**
492
+ * Fetch the windowed quota summary via `retrieveUserQuotaSummary`.
493
+ *
494
+ * Uses the same transport/UA/timeout conventions as `fetchAvailableModels`.
495
+ * On a 429 or 5xx against one endpoint, falls through to the next entry
496
+ * in `options.endpoints` (matching the legacy fetchers' failover
497
+ * convention). On 403 with the managedProjectId, retries with the
498
+ * regular projectId. If that also 403s, falls back to
499
+ * `fetchAvailableModels` so quota never goes dark. On missing
500
+ * managedProjectId, tries projectId first.
501
+ */
502
+ export async function fetchQuotaSummary(options) {
503
+ const timeoutMs = options.timeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS;
504
+ const userAgent = options.userAgent ?? buildAntigravityHarnessUserAgent();
505
+ const transport = options.fetchVia ?? defaultTransport;
506
+ const errors = [];
507
+ if (options.endpoints.length === 0) {
508
+ throw new Error('No endpoints configured for fetchQuotaSummary');
509
+ }
510
+ const tryBody = async (endpoint, projectId) => {
511
+ const body = { project: projectId };
512
+ try {
513
+ const response = await transport(`${endpoint}/v1internal:retrieveUserQuotaSummary`, {
514
+ method: 'POST',
515
+ headers: {
516
+ 'User-Agent': userAgent,
517
+ Authorization: `Bearer ${options.accessToken}`,
518
+ 'Content-Type': 'application/json',
519
+ 'Accept-Encoding': 'gzip',
520
+ },
521
+ body: JSON.stringify(body),
522
+ }, { timeoutMs });
523
+ if (response.ok) {
524
+ return {
525
+ ok: true,
526
+ summary: (await response.json()),
527
+ };
528
+ }
529
+ const status = response.status;
530
+ if (status === 403) {
531
+ errors.push(`retrieveUserQuotaSummary 403 at ${endpoint} (project=${projectId.slice(0, 12)}…)`);
532
+ return { ok: false, reason: '403' };
533
+ }
534
+ if (status === 429 || status >= 500) {
535
+ const message = await response.text().catch(() => '');
536
+ errors.push(`retrieveUserQuotaSummary ${status} at ${endpoint}${message ? `: ${message.trim().slice(0, 200)}` : ''}`);
537
+ // Endpoint failover: the caller may have multiple endpoints;
538
+ // continue to the next one with the same project ID.
539
+ return { ok: false, reason: 'transient' };
540
+ }
541
+ const message = await response.text().catch(() => '');
542
+ errors.push(`retrieveUserQuotaSummary ${status} at ${endpoint}${message ? `: ${message.trim().slice(0, 200)}` : ''}`);
543
+ return { ok: false, reason: 'transient' };
544
+ }
545
+ catch (error) {
546
+ errors.push(`retrieveUserQuotaSummary network error at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
547
+ return { ok: false, reason: 'transient' };
548
+ }
549
+ };
550
+ // Try managedProjectId first, then projectId, then legacy fallback.
551
+ // For each project, iterate the endpoint list so a 500/429 on the
552
+ // primary endpoint falls through to the next entry.
553
+ const primary = options.managedProjectId ?? options.projectId;
554
+ let primaryGot403 = false;
555
+ if (primary) {
556
+ for (const endpoint of options.endpoints) {
557
+ const result = await tryBody(endpoint, primary);
558
+ if (result.ok)
559
+ return { summary: result.summary };
560
+ if (result.reason === '403')
561
+ primaryGot403 = true;
562
+ }
563
+ }
564
+ // Retry with the regular projectId ONLY when the managed-project attempt
565
+ // got a 403 (i.e. the managed project does not own the user). A transient
566
+ // failure (429, 5xx, network) must NOT fall through here — the regular
567
+ // projectId could return a different project's quota data.
568
+ const fallbackId = primaryGot403 &&
569
+ options.managedProjectId &&
570
+ options.projectId &&
571
+ options.managedProjectId !== options.projectId
572
+ ? options.projectId
573
+ : undefined;
574
+ if (fallbackId) {
575
+ for (const endpoint of options.endpoints) {
576
+ const result = await tryBody(endpoint, fallbackId);
577
+ if (result.ok)
578
+ return { summary: result.summary };
579
+ }
580
+ }
581
+ // Give up — the caller should fall back to fetchAvailableModels.
582
+ throw new Error(errors.join('; ') || 'fetchQuotaSummary failed: no project ID available');
583
+ }
584
+ /**
585
+ * Aggregate Gemini CLI quota buckets into a summary.
586
+ */
587
+ export function aggregateGeminiCliQuota(response) {
588
+ const models = [];
589
+ if (!response.buckets || response.buckets.length === 0) {
590
+ return { models };
591
+ }
592
+ for (const bucket of response.buckets) {
593
+ if (!bucket.modelId) {
594
+ continue;
595
+ }
596
+ const modelId = bucket.modelId;
597
+ const isRelevantModel = modelId.startsWith('gemini-3-') ||
598
+ modelId.startsWith('gemini-3.') ||
599
+ modelId.startsWith('gemini-2.5-');
600
+ if (!isRelevantModel) {
601
+ continue;
602
+ }
603
+ models.push({
604
+ modelId: bucket.modelId,
605
+ remainingFraction: normalizeRemainingFraction(bucket.remainingFraction),
606
+ resetTime: bucket.resetTime,
607
+ });
608
+ }
609
+ models.sort((a, b) => a.modelId.localeCompare(b.modelId));
610
+ return { models };
611
+ }
612
+ export async function fetchAvailableModels(options) {
613
+ const timeoutMs = options.timeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS;
614
+ const userAgent = options.userAgent ?? buildAntigravityHarnessUserAgent();
615
+ const errors = [];
616
+ const transport = options.fetchVia ?? defaultTransport;
617
+ for (const endpoint of options.endpoints) {
618
+ const body = options.projectId ? { project: options.projectId } : {};
619
+ try {
620
+ const response = await transport(`${endpoint}/v1internal:fetchAvailableModels`, {
621
+ method: 'POST',
622
+ headers: {
623
+ 'User-Agent': userAgent,
624
+ Authorization: `Bearer ${options.accessToken}`,
625
+ 'Content-Type': 'application/json',
626
+ 'Accept-Encoding': 'gzip',
627
+ },
628
+ body: JSON.stringify(body),
629
+ }, { timeoutMs });
630
+ if (response.ok) {
631
+ return (await response.json());
632
+ }
633
+ const status = response.status;
634
+ if (status === 403 && options.projectId) {
635
+ try {
636
+ const retryResponse = await transport(`${endpoint}/v1internal:fetchAvailableModels`, {
637
+ method: 'POST',
638
+ headers: {
639
+ 'User-Agent': userAgent,
640
+ Authorization: `Bearer ${options.accessToken}`,
641
+ 'Content-Type': 'application/json',
642
+ 'Accept-Encoding': 'gzip',
643
+ },
644
+ body: JSON.stringify({}),
645
+ }, { timeoutMs });
646
+ if (retryResponse.ok) {
647
+ return (await retryResponse.json());
648
+ }
649
+ }
650
+ catch {
651
+ // Fall through to next endpoint
652
+ }
653
+ }
654
+ if (status === 429 || status >= 500) {
655
+ const message = await response.text().catch(() => '');
656
+ const snippet = message.trim().slice(0, 200);
657
+ errors.push(`fetchAvailableModels ${status} at ${endpoint}${snippet ? `: ${snippet}` : ''}`);
658
+ continue;
659
+ }
660
+ const message = await response.text().catch(() => '');
661
+ const snippet = message.trim().slice(0, 200);
662
+ errors.push(`fetchAvailableModels ${status} at ${endpoint}${snippet ? `: ${snippet}` : ''}`);
663
+ break;
664
+ }
665
+ catch (error) {
666
+ errors.push(`fetchAvailableModels network error at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
667
+ }
668
+ }
669
+ throw new Error(errors.join('; ') || 'fetchAvailableModels failed');
670
+ }
671
+ export async function fetchGeminiCliQuota(options) {
672
+ const timeoutMs = options.timeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS;
673
+ const userAgent = options.userAgent ?? buildAntigravityHarnessUserAgent();
674
+ const transport = options.fetchVia ?? defaultTransport;
675
+ const errors = [];
676
+ for (const endpoint of options.endpoints) {
677
+ const body = options.projectId ? { project: options.projectId } : {};
678
+ try {
679
+ const response = await transport(`${endpoint}/v1internal:retrieveUserQuota`, {
680
+ method: 'POST',
681
+ headers: {
682
+ Authorization: `Bearer ${options.accessToken}`,
683
+ 'Content-Type': 'application/json',
684
+ 'User-Agent': userAgent,
685
+ },
686
+ body: JSON.stringify(body),
687
+ }, { timeoutMs });
688
+ if (response.ok) {
689
+ return (await response.json());
690
+ }
691
+ const status = response.status;
692
+ if (status === 429 || status >= 500) {
693
+ errors.push(`fetchGeminiCliQuota ${status} at ${endpoint}`);
694
+ continue;
695
+ }
696
+ // Non-retryable server response (e.g. 403) — treat as no CLI quota.
697
+ return { buckets: [] };
698
+ }
699
+ catch (error) {
700
+ // Transport-level failure (network abort, DNS, timeout). Collect the
701
+ // error and try the next endpoint; if all endpoints fail, throw so
702
+ // the caller can distinguish a transient network failure from an
703
+ // authoritative 'no CLI quota configured' response.
704
+ errors.push(error instanceof Error ? error.message : String(error));
705
+ }
706
+ }
707
+ // All endpoints produced transport-level errors — propagate so the
708
+ // outer caller (quota.ts .catch) can surface the real failure reason
709
+ // rather than the generic 'No Gemini CLI quota available' message.
710
+ if (errors.length > 0) {
711
+ throw new Error(errors.join('; ') || 'fetchGeminiCliQuota failed');
712
+ }
713
+ return { buckets: [] };
714
+ }
715
+ async function defaultTransport(url, init, options) {
716
+ return fetchWithActiveTimeout(url, init, { timeoutMs: options.timeoutMs });
717
+ }
718
+ //# sourceMappingURL=quota-manager.js.map