@wenathlan/getry 2.0.165 → 2.0.167

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 (66) hide show
  1. package/README.md +1 -1
  2. package/accounts.ts +2087 -0
  3. package/antigravity.ts +2171 -0
  4. package/auth.ts +265 -0
  5. package/cli.ts +3 -3
  6. package/constants.ts +950 -0
  7. package/db.ts +810 -0
  8. package/docs/distributionlanes.md +1 -1
  9. package/docs/features.md +1 -2
  10. package/docs/gateway.md +2 -3
  11. package/docs/getry.md +33 -0
  12. package/engine.ts +1535 -0
  13. package/fingerprint.ts +1461 -0
  14. package/gateway.ts +1306 -0
  15. package/gatewaycheck.ts +142 -0
  16. package/intelligence.ts +169 -0
  17. package/json.ts +932 -0
  18. package/labels.ts +262 -0
  19. package/llm.ts +2709 -0
  20. package/models.ts +4017 -0
  21. package/nodeshim.ts +265 -0
  22. package/oauth.ts +2201 -0
  23. package/package.json +580 -225
  24. package/project.ts +1293 -0
  25. package/providers.ts +3238 -0
  26. package/quota.ts +1891 -0
  27. package/recovery.ts +2434 -0
  28. package/request.ts +4496 -0
  29. package/search.ts +1761 -0
  30. package/server.ts +766 -0
  31. package/streaming.ts +4398 -0
  32. package/{tests → test}/cli.test.ts +1 -1
  33. package/{tests → test}/gateway.test.ts +2 -2
  34. package/{tests → test}/gatewayversions.test.ts +1 -1
  35. package/{tests → test}/intelligence.test.ts +1 -1
  36. package/{tests → test}/keyrotation.test.ts +1 -1
  37. package/{tests → test}/onboardingstate.test.ts +1 -1
  38. package/{tests → test}/providerfixtures.ts +12 -12
  39. package/{tests → test}/researchfixtures.ts +1 -1
  40. package/{tests → test}/sandboxmodality.test.ts +1 -1
  41. package/thinking.ts +416 -0
  42. package/types.ts +809 -0
  43. package/utils.ts +263 -0
  44. package/validate.ts +61 -0
  45. package/versionregistry.ts +1325 -0
  46. /package/{tests → test}/debonairlane.test.ts +0 -0
  47. /package/{tests → test}/design.test.ts +0 -0
  48. /package/{tests → test}/family.test.ts +0 -0
  49. /package/{tests → test}/gatewayserver.ts +0 -0
  50. /package/{tests → test}/jsonpolicy.test.ts +0 -0
  51. /package/{tests → test}/llm.test.ts +0 -0
  52. /package/{tests → test}/modelroute.test.ts +0 -0
  53. /package/{tests → test}/promptlibrary.test.ts +0 -0
  54. /package/{tests → test}/providercli.test.ts +0 -0
  55. /package/{tests → test}/providerconstants.test.ts +0 -0
  56. /package/{tests → test}/providerfingerprint.test.ts +0 -0
  57. /package/{tests → test}/providermodels.test.ts +0 -0
  58. /package/{tests → test}/provideroauth.test.ts +0 -0
  59. /package/{tests → test}/routerotation.test.ts +0 -0
  60. /package/{tests → test}/serverauth.test.ts +0 -0
  61. /package/{tests → test}/serverengine.test.ts +0 -0
  62. /package/{tests → test}/serverproperty.test.ts +0 -0
  63. /package/{tests → test}/serverutils.test.ts +0 -0
  64. /package/{tests → test}/streamfallback.test.ts +0 -0
  65. /package/{tests → test}/thinkingladder.test.ts +0 -0
  66. /package/{tests → test}/torturellm.test.ts +0 -0
package/quota.ts ADDED
@@ -0,0 +1,1891 @@
1
+ /**
2
+ * @fileoverview quota.ts — the full quota manager of the merged provider lineage
3
+ * @module auth/quota
4
+ * @description
5
+ * Merged module. Lineage A (dual-source Antigravity + Gemini CLI quota with
6
+ * adaptive TTL cache, soft 90% threshold, background refresh, resilient
7
+ * parsing of v1internal:retrieveUserQuotaSummary) is the canonical surface.
8
+ *
9
+ * v2.1.16 dead-code cleanup: the unused resilient lineage-B quota engine
10
+ * (zero consumers) was deleted. It duplicated lineage A as a second complete
11
+ * engine under aliased names — resolveQuotaPoolGroup (+ resolveGroup /
12
+ * getQuotaGroupForModel / modelToGroup), parseQuotaResponseResilient,
13
+ * retrieveUserQuotaSummaryResilient, checkQuotaFromSummary,
14
+ * isQuotaExhaustedBySummary, shouldSkipAccountBySummary,
15
+ * QuotaManagerResilient (+ getQuotaManager / resetQuotaManager /
16
+ * fetchQuotaWithCache / refreshQuotaBackground), getAdaptiveTtlMs,
17
+ * resolveBaseTtlFromConfig, parseRateLimitHeaders, hashToken's engine
18
+ * cluster, QUOTA_INFO and the Quota facade — every one verified by grep to
19
+ * have zero consumers outside quota.ts before deletion.
20
+ *
21
+ * Canonical (lineage A) surface: OAUTH_CLIENT_ID/OAUTH_CLIENT_SECRET/GEMINI_CLI_SCOPES,
22
+ * PROJECT_FALLBACK, ENDPOINTS/ENDPOINTS_DAILY/QUOTA_ENDPOINTS + QuotaSource,
23
+ * QUOTA_GROUP_DEFS/QuotaGroupId/ResilientQuotaGroupDef, QUOTA_METHOD,
24
+ * QuotaWindow, QuotaBucketRaw/Parsed,
25
+ * QuotaGroupRaw/Parsed, QuotaSummaryRaw/Parsed, computeAdaptiveTtlMs,
26
+ * getQuotaRefreshIntervalMs, listQuotaGroups/getQuotaGroup, buildQuotaHeaders(token,source),
27
+ * parseQuotaBucket/parseQuotaGroup, QuotaFetchError, retrieveUserQuotaSummary,
28
+ * retrieveDualQuota, QuotaCacheManager + getQuotaCacheManager, QuotaManager +
29
+ * getDefaultQuotaManager, canProceedWithRequest, QuotaConfig/DEFAULT_QUOTA_CONFIG/
30
+ * resolveQuotaConfig, QUOTA_CACHE_FILE, hashToken.
31
+ *
32
+ * Production-ready, only node:* builtins + global fetch. Zero external deps.
33
+ *
34
+ * @author ONDA 3 - Core Modules (A) + the provider lineage (B)
35
+ * @license MIT
36
+ * @since 2026
37
+ */
38
+
39
+ import * as fs from "node:fs";
40
+ import * as os from "node:os";
41
+ import * as path from "node:path";
42
+ import * as crypto from "node:crypto";
43
+ import { createHash } from "node:crypto";
44
+
45
+ // Deduplicated against the frozen owners (constants.ts / models.ts) and the
46
+ // canonical in-set file (project.ts): symbols whose local definitions were
47
+ // value-identical are imported and re-exported below, so each has exactly one
48
+ // definition (library dedupe task 2-d1).
49
+ import {
50
+ PROJECT_FALLBACK,
51
+ QUOTA_SOFT_THRESHOLD,
52
+ QUOTA_CACHE_TTL_MIN_MS,
53
+ QUOTA_CACHE_TTL_MS,
54
+ QUOTA_TIMEOUT_MS,
55
+ ANTIGRAVITY_VERSION_FALLBACK,
56
+ ANTIGRAVITY_USER_AGENT_FALLBACK,
57
+ ANTIGRAVITY_CLIENT_ID_IS_BLINDED,
58
+ ANTIGRAVITY_CLIENT_SECRET_IS_BLINDED,
59
+ ANTIGRAVITY_ENDPOINT_FALLBACKS,
60
+ GEMINI_CLI_ENDPOINT,
61
+ QUOTA_ENDPOINT,
62
+ PROJECT_ID_FALLBACK,
63
+ PROJECT_NAME_FALLBACK,
64
+ } from "./constants.js";
65
+ /* the os leaf jitter pair stays devthink-owned behind the published
66
+ * specifier — never copied into the gateway. */
67
+ import { JITTER_MIN_MS, JITTER_MAX_MS } from "@wenathlan/devthink/constants";
68
+ import { fnv1a32, isRetryableStatus } from "@wenathlan/devthink/core";
69
+ import { getXGoogApiClient } from "./fingerprint.js";
70
+ import type { QuotaGroup } from "./constants.js";
71
+ import { ANTIGRAVITY_VERSION_MIN, isGeminiCLIOnlyModel, MODELS_2026_LEGACY, UA_DEFAULT_MODEL } from "./models.js";
72
+ import { API_PATHS, API_PATHS_DAILY } from "./request.js";
73
+ import {
74
+ QUOTA_CACHE_TTL_MAX_MS,
75
+ QUOTA_HARD_THRESHOLD,
76
+ CLIENT_METADATA_STRING,
77
+ GEMINI_CLI_OAUTH_CLIENT_ID,
78
+ GEMINI_CLI_OAUTH_CLIENT_SECRET,
79
+ GEMINI_CLI_USER_AGENT,
80
+ } from "./constants.js";
81
+ import { X_GOOG_API_CLIENT_GEMINI_CLI as X_GOOG_API_CLIENT } from "./fingerprint.js";
82
+
83
+ // Re-exports preserve this module's public surface after the dedupe.
84
+ export {
85
+ PROJECT_FALLBACK,
86
+ QUOTA_SOFT_THRESHOLD,
87
+ QUOTA_CACHE_TTL_MIN_MS,
88
+ QUOTA_CACHE_TTL_MS,
89
+ QUOTA_TIMEOUT_MS,
90
+ JITTER_MIN_MS,
91
+ JITTER_MAX_MS,
92
+ ANTIGRAVITY_VERSION_FALLBACK,
93
+ ANTIGRAVITY_USER_AGENT_FALLBACK,
94
+ ANTIGRAVITY_CLIENT_ID_IS_BLINDED,
95
+ ANTIGRAVITY_CLIENT_SECRET_IS_BLINDED,
96
+ ANTIGRAVITY_ENDPOINT_FALLBACKS,
97
+ GEMINI_CLI_ENDPOINT,
98
+ QUOTA_ENDPOINT,
99
+ PROJECT_ID_FALLBACK,
100
+ PROJECT_NAME_FALLBACK,
101
+ getXGoogApiClient,
102
+ ANTIGRAVITY_VERSION_MIN,
103
+ isRetryableStatus,
104
+ X_GOOG_API_CLIENT,
105
+ CLIENT_METADATA_STRING,
106
+ };
107
+ // v2.1.15 Phase C: the historical OAUTH_CLIENT_ID/OAUTH_CLIENT_SECRET names
108
+ // (gemini-cli pair) are re-exported from the constants.js owner.
109
+ export {
110
+ GEMINI_CLI_OAUTH_CLIENT_ID as OAUTH_CLIENT_ID,
111
+ GEMINI_CLI_OAUTH_CLIENT_SECRET as OAUTH_CLIENT_SECRET,
112
+ } from "./constants.js";
113
+ export type { QuotaGroup };
114
+
115
+ // =============================================================================
116
+ // BLINDED CONSTANTS - GEMINI CLI BYPASS
117
+ // =============================================================================
118
+
119
+ // OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET: static Gemini CLI client values
120
+ // identical to the canonical project.ts definitions — imported and re-exported
121
+ // above (constants.ts exports the same values under the
122
+ // GEMINI_CLI_OAUTH_CLIENT_ID / GEMINI_CLI_OAUTH_CLIENT_SECRET names).
123
+
124
+ // v2.1.15 Phase C: GEMINI_CLI_SCOPES is owned by constants.js
125
+ // (GEMINI_CLI_SCOPES); quota.ts no longer imports it (the v2.1.16 cleanup
126
+ // removed its last dead local reference).
127
+
128
+ // PROJECT_FALLBACK: identical "rising-fact-p41fc" — imported and re-exported above.
129
+
130
+ // local variant: no same-named owner export (constants owns CODE_ASSIST_ENDPOINTS / GEMINI_CLI_ENDPOINT under other names)
131
+
132
+ // v2.1.15 Phase D: the method-path maps moved to request.ts as API_PATHS / API_PATHS_DAILY.
133
+
134
+ /**
135
+ * Dual quota mapping: Antigravity uses the daily endpoint (real quota),
136
+ * Gemini CLI uses the base endpoint (historically returns 1.0 but still queried).
137
+ */
138
+ export const QUOTA_ENDPOINTS = {
139
+ antigravity: API_PATHS_DAILY.retrieveUserQuotaSummary,
140
+ "gemini-cli": API_PATHS.retrieveUserQuotaSummary,
141
+ } as const;
142
+
143
+ export type QuotaSource = keyof typeof QUOTA_ENDPOINTS; // 'antigravity' | 'gemini-cli'
144
+
145
+ // local variant: diverges from constants (static full GeminiCLI UA vs the dynamic GEMINI_CLI_USER_AGENT_FALLBACK "gemini-cli/0.57.0 os/arch")
146
+
147
+ export const GEMINI_CLI_USER_AGENT_ANTIGRAVITY =
148
+ "antigravity/1.12.8 (linux; x64; VSCode/1.90.0; Antigravity) google-api-nodejs-client/9.15.1" as const;
149
+
150
+ // X_GOOG_API_CLIENT: static "gl-node/22.19.0" identical to the canonical
151
+ // project.ts definition — imported and re-exported above (constants.ts's
152
+ // same-named symbol is dynamic and therefore divergent).
153
+
154
+ // local variant: no same-named owner export (Antigravity IDE metadata string)
155
+ export const CLIENT_METADATA_ANTIGRAVITY =
156
+ "ideType=IDE_VSCODE,platform=PLATFORM_UNSPECIFIED,pluginType=ANTIGRAVITY" as const;
157
+
158
+ // CLIENT_METADATA_STRING: identical to the canonical project.ts definition —
159
+ // imported and re-exported above.
160
+
161
+ // v2.1.15 Phase A: the local MODELS_2026 (11 legacy ids) and Model2026 type
162
+ // were byte-identical to models.ts MODELS_2026_LEGACY and were deleted — the
163
+ // canonical family names live only in models.ts.
164
+
165
+ // =============================================================================
166
+ // QUOTA GROUPS & TTL CONSTANTS
167
+ // =============================================================================
168
+
169
+ /**
170
+ * Default interval for quota refresh (minutes).
171
+ * Can be overridden via config or env QUOTA_REFRESH_INTERVAL_MINUTES.
172
+ */
173
+ export const QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT = 15 as const;
174
+ export const QUOTA_REFRESH_INTERVAL_MS_DEFAULT = QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT * 60 * 1000;
175
+
176
+ // QUOTA_SOFT_THRESHOLD: identical 0.9 — imported and re-exported above.
177
+ export const QUOTA_SOFT_REMAINING = 1 - QUOTA_SOFT_THRESHOLD; // 0.1
178
+
179
+ // v2.1.15 Phase D: QUOTA_HARD_THRESHOLD (0.98 lineage A) is the canonical
180
+ // value in constants.js — imported from the owner.
181
+ export const QUOTA_CACHE_TTL_MS_DEFAULT = 15 * 60 * 1000; // 15min default
182
+ // QUOTA_CACHE_TTL_MIN_MS: identical 2min — imported and re-exported above.
183
+ // v2.1.15 Phase D: QUOTA_CACHE_TTL_MAX_MS (30min lineage A) is the canonical
184
+ // value in constants.js — imported from the owner.
185
+ export const QUOTA_CACHE_FILE = path.join(os.homedir(), ".config", "opencode", "cache", "antigravity", "quota.json");
186
+
187
+ export const QUOTA_REQUEST_TIMEOUT_MS = 10_000 as const;
188
+
189
+ // Canonical definition of the internal logical groups (used by resolveQuotaGroup)
190
+ export type QuotaGroupId =
191
+ | "gemini-3-pro"
192
+ | "gemini-3.1-pro"
193
+ | "gemini-3-flash"
194
+ | "gemini-2.5-pro"
195
+ | "gemini-2.5-flash"
196
+ | "claude-sonnet"
197
+ | "claude-opus"
198
+ | "gemini" // generic grouping returned by the API (Gemini Models)
199
+ | "claude-3p" // generic Claude+GPT grouping (3p)
200
+ | "unknown";
201
+
202
+ // v2.1.15 Phase C: renamed from QuotaGroupDef (the former constants.js
203
+ // group/models[] shape was deleted in v2.1.16 as dead code) — this is the
204
+ // live quota-group definition used by resolveQuotaGroup/getQuotaGroup.
205
+ export interface ResilientQuotaGroupDef {
206
+ id: QuotaGroupId;
207
+ displayNamePattern: RegExp; // regex para casar displayName da API
208
+ models: readonly string[];
209
+ dailyLimit: number;
210
+ rpm: number;
211
+ tpm: number;
212
+ }
213
+
214
+ /**
215
+ * Quota-group routing table. models.ts owns ALL model identification
216
+ * (catalog, rosters, aliases); this table is quota-group routing derived
217
+ * from the models.ts catalog (v2.1.16 dedup note): the ids below are the
218
+ * quota-specific routing keys, and the antigravity-prefixed claude ids are
219
+ * quota-route spellings with no single-id export in models.ts — kept local
220
+ * so quota routing stays byte-identical (no behavior change).
221
+ */
222
+ export const QUOTA_GROUP_DEFS: readonly ResilientQuotaGroupDef[] = [
223
+ {
224
+ id: "gemini-3-pro",
225
+ displayNamePattern: /gemini.*3.*pro/i,
226
+ models: ["antigravity-gemini-3-pro", "gemini-3-pro-preview"] as const,
227
+ dailyLimit: 1000,
228
+ rpm: 60,
229
+ tpm: 1_000_000,
230
+ },
231
+ {
232
+ id: "gemini-3.1-pro",
233
+ displayNamePattern: /gemini.*3\.1.*pro/i,
234
+ models: ["antigravity-gemini-3.1-pro", "gemini-3.1-pro-preview", "gemini-3.1-pro-preview-customtools"] as const,
235
+ dailyLimit: 500,
236
+ rpm: 30,
237
+ tpm: 2_000_000,
238
+ },
239
+ {
240
+ id: "gemini-3-flash",
241
+ displayNamePattern: /gemini.*3.*flash|flash/i,
242
+ models: ["antigravity-gemini-3-flash", "gemini-3-flash-preview"] as const,
243
+ dailyLimit: 2000,
244
+ rpm: 120,
245
+ tpm: 1_000_000,
246
+ },
247
+ {
248
+ id: "gemini-2.5-pro",
249
+ displayNamePattern: /gemini.*2\.5.*pro/i,
250
+ models: ["gemini-2.5-pro"] as const,
251
+ dailyLimit: 1500,
252
+ rpm: 60,
253
+ tpm: 1_000_000,
254
+ },
255
+ {
256
+ id: "gemini-2.5-flash",
257
+ displayNamePattern: /gemini.*2\.5.*flash/i,
258
+ models: ["gemini-2.5-flash"] as const,
259
+ dailyLimit: 3000,
260
+ rpm: 240,
261
+ tpm: 1_000_000,
262
+ },
263
+ {
264
+ id: "claude-sonnet",
265
+ displayNamePattern: /claude.*sonnet|3p|third.party/i,
266
+ models: ["antigravity-claude-sonnet-4-6"] as const,
267
+ dailyLimit: 500,
268
+ rpm: 30,
269
+ tpm: 200_000,
270
+ },
271
+ {
272
+ id: "claude-opus",
273
+ displayNamePattern: /claude.*opus/i,
274
+ models: ["antigravity-claude-opus-4-6-thinking"] as const,
275
+ dailyLimit: 250,
276
+ rpm: 15,
277
+ tpm: 200_000,
278
+ },
279
+ ] as const;
280
+
281
+ // =============================================================================
282
+ // TYPES - QUOTA API RESPONSE
283
+ // =============================================================================
284
+
285
+ export type QuotaWindow = "weekly" | "5h" | "daily" | "hourly" | "monthly" | string;
286
+
287
+ export interface QuotaBucketRaw {
288
+ bucketId: string; // ex: "gemini-weekly", "gemini-5h", "3p-weekly"
289
+ displayName?: string; // "Weekly Limit", "Five Hour Limit"
290
+ window?: QuotaWindow;
291
+ resetTime?: string; // ISO "2026-07-25T01:43:09Z"
292
+ description?: string;
293
+ remainingFraction?: number; // 0.0 - 1.0
294
+ remaining?: number; // numeric alternative
295
+ limit?: number;
296
+ used?: number;
297
+ }
298
+
299
+ export interface QuotaGroupRaw {
300
+ displayName?: string; // "Gemini Models", "Claude and GPT models"
301
+ description?: string; // "Models within this group: Gemini Flash, Gemini Pro"
302
+ buckets?: QuotaBucketRaw[];
303
+ quotaGroup?: string; // backend sometimes returns an id here
304
+ }
305
+
306
+ export interface QuotaSummaryRaw {
307
+ groups?: QuotaGroupRaw[];
308
+ quotaGroup?: QuotaGroupRaw[]; // fallback variante
309
+ // Alguns payloads retornam diretamente buckets no root
310
+ buckets?: QuotaBucketRaw[];
311
+ }
312
+
313
+ export interface QuotaBucketParsed {
314
+ bucketId: string;
315
+ displayName: string;
316
+ window: QuotaWindow;
317
+ resetTime: string | null; // ISO persistido
318
+ resetTimeMs: number | null; // epoch ms for calculations
319
+ remainingFraction: number; // normalizado 0..1
320
+ remainingPercent: number; // 0..100
321
+ isExhausted: boolean; // remaining <= 0.02
322
+ isSoftExhausted: boolean; // remaining <= 0.1 (90% threshold)
323
+ description: string;
324
+ }
325
+
326
+ export interface QuotaGroupParsed {
327
+ id: QuotaGroupId; // resolvido via mapping ou unknown
328
+ displayName: string;
329
+ description: string;
330
+ rawDisplayName: string;
331
+ source: QuotaSource;
332
+ buckets: QuotaBucketParsed[];
333
+ // Helpers agregados
334
+ weeklyRemaining: number | null;
335
+ fiveHourRemaining: number | null;
336
+ minRemainingFraction: number; // menor remaining entre buckets (pior caso)
337
+ minBucket: QuotaBucketParsed | null;
338
+ isExhausted: boolean;
339
+ isSoftExhausted: boolean;
340
+ }
341
+
342
+ export interface QuotaSummaryParsed {
343
+ groups: QuotaGroupParsed[];
344
+ fetchedAt: number;
345
+ expiresAt: number;
346
+ ttlMs: number;
347
+ source: QuotaSource;
348
+ projectId: string;
349
+ // agregados globais
350
+ globalMinRemaining: number;
351
+ isAnyExhausted: boolean;
352
+ isAnySoftExhausted: boolean;
353
+ }
354
+
355
+ export interface QuotaCacheEntry {
356
+ summary: QuotaSummaryParsed;
357
+ raw: QuotaSummaryRaw;
358
+ tokenHash: string; // token hash used for invalidation
359
+ cachedAt: number;
360
+ expiresAt: number;
361
+ ttlMs: number;
362
+ }
363
+
364
+ export interface CheckQuotaResult {
365
+ allowed: boolean;
366
+ groupId: QuotaGroupId | null;
367
+ bucketId: string | null;
368
+ remainingFraction: number | null;
369
+ remainingPercent: number | null;
370
+ resetTime: string | null;
371
+ resetTimeMs: number | null;
372
+ reason: string;
373
+ isSoftThreshold: boolean;
374
+ isHardExhausted: boolean;
375
+ shouldSkipAccount: boolean;
376
+ source: QuotaSource;
377
+ group?: QuotaGroupParsed | undefined;
378
+ bucket?: QuotaBucketParsed | undefined;
379
+ }
380
+
381
+ // =============================================================================
382
+ // UTILS
383
+ // =============================================================================
384
+
385
+ // hashToken (SHA-256 truncated to 32 hex chars with FNV-1a fallback) is
386
+ // defined below next to the cache layer and keys both cache levels.
387
+
388
+ function parseIsoToMs(iso?: string | null): number | null {
389
+ if (!iso) return null;
390
+ const ms = Date.parse(iso);
391
+ return Number.isNaN(ms) ? null : ms;
392
+ }
393
+
394
+ function clampFraction(n: unknown): number {
395
+ if (typeof n !== "number" || Number.isNaN(n)) return 1.0;
396
+ if (n < 0) return 0;
397
+ if (n > 1) return 1;
398
+ return n;
399
+ }
400
+
401
+ function nowMs(): number {
402
+ return Date.now();
403
+ }
404
+
405
+ /**
406
+ * Adaptive TTL: the lower the remaining fraction, the shorter the TTL so
407
+ * refresh happen sooner.
408
+ * - <10% => 2min
409
+ * - <20% => 5min
410
+ * - <50% => 10min
411
+ * - >=50% => 15min default, still honoring a nearby resetTime
412
+ */
413
+ export function computeAdaptiveTtlMs(
414
+ summary: QuotaSummaryRaw | QuotaSummaryParsed,
415
+ baseTtlMs: number = QUOTA_CACHE_TTL_MS_DEFAULT,
416
+ ): number {
417
+ let minRemaining = 1.0;
418
+
419
+ // Extract the lowest remainingFraction
420
+ if ("groups" in summary && Array.isArray((summary as any).groups)) {
421
+ const groups = (summary as QuotaGroupParsed[] | QuotaGroupRaw[]).flatMap((g: any) =>
422
+ Array.isArray(g.buckets) ? g.buckets : [],
423
+ );
424
+ for (const b of groups) {
425
+ const f = clampFraction((b as any).remainingFraction ?? (b as any).remaining ?? 1);
426
+ if (f < minRemaining) minRemaining = f;
427
+ }
428
+ }
429
+
430
+ // When Parsed already carries globalMin
431
+ if ("globalMinRemaining" in summary) {
432
+ minRemaining = (summary as QuotaSummaryParsed).globalMinRemaining;
433
+ }
434
+
435
+ let ttl = baseTtlMs;
436
+
437
+ if (minRemaining <= QUOTA_SOFT_REMAINING) {
438
+ ttl = QUOTA_CACHE_TTL_MIN_MS; // 2min
439
+ } else if (minRemaining <= 0.2) {
440
+ ttl = 5 * 60 * 1000;
441
+ } else if (minRemaining <= 0.5) {
442
+ ttl = 10 * 60 * 1000;
443
+ } else {
444
+ ttl = baseTtlMs;
445
+ }
446
+
447
+ // Adjust when a resetTime is very close (e.g. < ttl)
448
+ let nearestResetMs: number | null = null;
449
+ if ("groups" in summary) {
450
+ for (const g of (summary as any).groups as QuotaGroupParsed[]) {
451
+ for (const b of g.buckets ?? []) {
452
+ const rt = (b as QuotaBucketParsed).resetTimeMs ?? parseIsoToMs((b as any).resetTime);
453
+ if (rt != null) {
454
+ if (nearestResetMs == null || rt < nearestResetMs) nearestResetMs = rt;
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ if (nearestResetMs != null) {
461
+ const msUntilReset = nearestResetMs - nowMs();
462
+ if (msUntilReset > 0 && msUntilReset < ttl) {
463
+ // If reset precedes the TTL, schedule refresh right after reset + 30s jitter
464
+ ttl = Math.max(QUOTA_CACHE_TTL_MIN_MS, msUntilReset + 30_000);
465
+ }
466
+ }
467
+
468
+ return Math.max(QUOTA_CACHE_TTL_MIN_MS, Math.min(ttl, QUOTA_CACHE_TTL_MAX_MS));
469
+ }
470
+
471
+ export function getQuotaRefreshIntervalMs(configMinutes?: number): number {
472
+ const envVal = process.env.QUOTA_REFRESH_INTERVAL_MINUTES ?? process.env.ANTIGRAVITY_QUOTA_REFRESH_MINUTES;
473
+ let minutes = configMinutes ?? QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT;
474
+
475
+ if (envVal != null) {
476
+ const parsed = Number.parseInt(envVal, 10);
477
+ if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 120) {
478
+ minutes = parsed;
479
+ }
480
+ }
481
+
482
+ if (minutes <= 0) minutes = QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT;
483
+ if (minutes > 120) minutes = 120;
484
+
485
+ return minutes * 60 * 1000;
486
+ }
487
+
488
+ // =============================================================================
489
+ // MODEL -> GROUP RESOLUTION
490
+ // =============================================================================
491
+
492
+ /**
493
+ * Maps a model string onto the internal QuotaGroupId.
494
+ * Supports both antigravity-* models and native gemini-* models.
495
+ * models.ts owns ALL model identification; this map is quota-group routing
496
+ * derived from the models.ts catalog (v2.1.16 dedup note — values kept
497
+ * byte-identical, no behavior change).
498
+ */
499
+ const MODEL_TO_GROUP_EXACT: Record<string, QuotaGroupId> = {
500
+ "antigravity-gemini-3-pro": "gemini-3-pro",
501
+ "gemini-3-pro-preview": "gemini-3-pro",
502
+ "antigravity-gemini-3.1-pro": "gemini-3.1-pro",
503
+ "gemini-3.1-pro-preview": "gemini-3.1-pro",
504
+ "gemini-3.1-pro-preview-customtools": "gemini-3.1-pro",
505
+ "antigravity-gemini-3-flash": "gemini-3-flash",
506
+ "gemini-3-flash-preview": "gemini-3-flash",
507
+ "gemini-2.5-pro": "gemini-2.5-pro",
508
+ "gemini-2.5-flash": "gemini-2.5-flash",
509
+ "antigravity-claude-sonnet-4-6": "claude-sonnet",
510
+ "antigravity-claude-opus-4-6-thinking": "claude-opus",
511
+
512
+ // Generic API group aliases
513
+ gemini: "gemini",
514
+ claude: "claude-3p",
515
+ gpt: "claude-3p",
516
+ "3p": "claude-3p",
517
+ "claude-3p": "claude-3p",
518
+ };
519
+
520
+ export function resolveQuotaGroup(model: string): QuotaGroupId {
521
+ if (!model || typeof model !== "string") return "unknown";
522
+
523
+ const lower = model.trim().toLowerCase();
524
+
525
+ // Exact
526
+ if (MODEL_TO_GROUP_EXACT[lower]) return MODEL_TO_GROUP_EXACT[lower];
527
+ if (MODEL_TO_GROUP_EXACT[model]) return MODEL_TO_GROUP_EXACT[model];
528
+
529
+ // Heuristics
530
+ if (lower.includes("opus")) return "claude-opus";
531
+ if (lower.includes("sonnet") || lower.includes("claude") || lower.includes("3p") || lower.includes("gpt")) {
532
+ // distinguish opus (handled above)
533
+ if (lower.includes("sonnet")) return "claude-sonnet";
534
+ return "claude-3p";
535
+ }
536
+ if (lower.includes("3.1") && lower.includes("pro")) return "gemini-3.1-pro";
537
+ if (lower.includes("3") && lower.includes("pro")) return "gemini-3-pro";
538
+ if (lower.includes("2.5") && lower.includes("flash")) return "gemini-2.5-flash";
539
+ if (lower.includes("2.5") && lower.includes("pro")) return "gemini-2.5-pro";
540
+ if (lower.includes("flash")) return "gemini-3-flash";
541
+
542
+ // API display names
543
+ if (lower.includes("gemini models") || lower === "gemini") return "gemini";
544
+ if (lower.includes("claude and gpt") || lower.includes("claude")) return "claude-3p";
545
+
546
+ return "unknown";
547
+ }
548
+
549
+ /**
550
+ * Returns the group definition for a model or groupId.
551
+ * Returns null when the groupId is unknown.
552
+ */
553
+ export function getQuotaGroup(input: string): ResilientQuotaGroupDef | null {
554
+ if (!input) return null;
555
+ const gid = resolveQuotaGroup(input);
556
+ if (gid === "unknown") {
557
+ // try matching by displayName pattern
558
+ for (const def of QUOTA_GROUP_DEFS) {
559
+ if (def.displayNamePattern.test(input)) return def;
560
+ }
561
+ return null;
562
+ }
563
+ // generic groupId -> return the closest equivalent def
564
+ if (gid === "gemini") {
565
+ return QUOTA_GROUP_DEFS.find((d) => d.id === "gemini-3-pro") ?? null;
566
+ }
567
+ if (gid === "claude-3p") {
568
+ return QUOTA_GROUP_DEFS.find((d) => d.id === "claude-sonnet") ?? null;
569
+ }
570
+ return QUOTA_GROUP_DEFS.find((d) => d.id === gid) ?? null;
571
+ }
572
+
573
+ /**
574
+ * Lists every available group definition.
575
+ */
576
+ export function listQuotaGroups(): readonly ResilientQuotaGroupDef[] {
577
+ return QUOTA_GROUP_DEFS;
578
+ }
579
+
580
+ // =============================================================================
581
+ // BUILD HEADERS - GEMINI CLI BYPASS
582
+ // =============================================================================
583
+
584
+ function buildDynamicUserAgent(source: QuotaSource): string {
585
+ const platform = os.platform(); // darwin, linux, win32
586
+ const arch = os.arch(); // x64, arm64
587
+
588
+ // Keeps coherence with the local real platform, but allows env override
589
+ const envUA = process.env.GEMINI_CLI_USER_AGENT ?? process.env.ANTIGRAVITY_USER_AGENT;
590
+ if (envUA && envUA.length > 10) return envUA;
591
+
592
+ if (source === "antigravity") {
593
+ // Usa template antigravity para daily endpoint. Local variants of the
594
+ // GEMINI_CLI_USER_AGENT_ANTIGRAVITY family: the per-platform templates
595
+ // carry the models.js UA default model segment (v2.1.16 dedup).
596
+ if (platform === "darwin" && arch === "arm64") {
597
+ return `antigravity/1.12.8/${UA_DEFAULT_MODEL} (darwin; arm64; VSCode/1.90.0) google-api-nodejs-client/9.15.1`;
598
+ }
599
+ if (platform === "win32") {
600
+ return `antigravity/1.12.8/${UA_DEFAULT_MODEL} (win32; x64; VSCode/1.90.0) google-api-nodejs-client/9.15.1`;
601
+ }
602
+ return GEMINI_CLI_USER_AGENT_ANTIGRAVITY;
603
+ }
604
+
605
+ // gemini-cli source: varia por plataforma real. v2.1.16 dedup: the
606
+ // darwin/win32 templates derive from the constants.js GEMINI_CLI_USER_AGENT
607
+ // owner by platform-segment substitution (byte-identical results).
608
+ if (platform === "darwin" && arch === "arm64") {
609
+ return GEMINI_CLI_USER_AGENT.replace("(linux; x64; GitHub)", "(darwin; arm64; GitHub)");
610
+ }
611
+ if (platform === "darwin") {
612
+ return GEMINI_CLI_USER_AGENT.replace("(linux; x64; GitHub)", "(darwin; x64; GitHub)");
613
+ }
614
+ if (platform === "win32") {
615
+ return GEMINI_CLI_USER_AGENT.replace("(linux; x64; GitHub)", "(win32; x64; GitHub)");
616
+ }
617
+ return GEMINI_CLI_USER_AGENT;
618
+ }
619
+
620
+ export function buildQuotaHeaders(token: string, source: QuotaSource = "antigravity"): Record<string, string> {
621
+ const ua = buildDynamicUserAgent(source);
622
+ const xGoog = process.env.X_GOOG_API_CLIENT ?? X_GOOG_API_CLIENT;
623
+ const clientMeta = source === "antigravity" ? CLIENT_METADATA_ANTIGRAVITY : CLIENT_METADATA_STRING;
624
+
625
+ return {
626
+ Authorization: `Bearer ${token}`,
627
+ "Content-Type": "application/json",
628
+ "User-Agent": ua,
629
+ "X-Goog-Api-Client": xGoog,
630
+ "Client-Metadata": clientMeta,
631
+ Accept: "application/json",
632
+ "Accept-Encoding": "gzip, deflate, br",
633
+ };
634
+ }
635
+
636
+ function getEndpointForSource(source: QuotaSource): string {
637
+ return QUOTA_ENDPOINTS[source] ?? QUOTA_ENDPOINTS.antigravity;
638
+ }
639
+
640
+ // =============================================================================
641
+ // PARSE QUOTA RESPONSE - ROBUST
642
+ // =============================================================================
643
+
644
+ export function parseQuotaBucket(raw: QuotaBucketRaw, source: QuotaSource): QuotaBucketParsed {
645
+ const bucketId = String(raw.bucketId ?? raw.displayName ?? "unknown").toLowerCase();
646
+ const displayName = String(raw.displayName ?? raw.bucketId ?? "Unknown").trim() || "Unknown";
647
+ const window = (raw.window ??
648
+ (bucketId.includes("weekly") ? "weekly" : bucketId.includes("5h") ? "5h" : "unknown")) as QuotaWindow;
649
+ const resetIso = raw.resetTime ?? null;
650
+ const resetMs = parseIsoToMs(resetIso);
651
+
652
+ let remainingFraction: number;
653
+ if (typeof raw.remainingFraction === "number") {
654
+ remainingFraction = clampFraction(raw.remainingFraction);
655
+ } else if (typeof raw.remaining === "number" && typeof raw.limit === "number" && raw.limit > 0) {
656
+ remainingFraction = clampFraction(raw.remaining / raw.limit);
657
+ } else if (typeof raw.remaining === "number") {
658
+ // Already a fraction?
659
+ remainingFraction = raw.remaining > 1 ? clampFraction(raw.remaining / 100) : clampFraction(raw.remaining);
660
+ } else if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.limit > 0) {
661
+ remainingFraction = clampFraction(1 - raw.used / raw.limit);
662
+ } else {
663
+ // Fallback: cloudcode-pa always returns 1.0 (known bug) -> treat as 1.0 but flag warning
664
+ remainingFraction = 1.0;
665
+ }
666
+
667
+ const remainingPercent = Math.round(remainingFraction * 10000) / 100; // 2 decimais
668
+ const isExhausted = remainingFraction <= 1 - QUOTA_HARD_THRESHOLD || remainingFraction <= 0.02;
669
+ const isSoftExhausted = remainingFraction <= QUOTA_SOFT_REMAINING;
670
+
671
+ return {
672
+ bucketId,
673
+ displayName,
674
+ window,
675
+ resetTime: resetIso,
676
+ resetTimeMs: resetMs,
677
+ remainingFraction,
678
+ remainingPercent,
679
+ isExhausted,
680
+ isSoftExhausted,
681
+ description: String(raw.description ?? "").trim(),
682
+ };
683
+ }
684
+
685
+ export function parseQuotaGroup(raw: QuotaGroupRaw, source: QuotaSource): QuotaGroupParsed {
686
+ const displayNameRaw = String(raw.displayName ?? raw.quotaGroup ?? "Unknown").trim();
687
+ // Resolve id heuristically
688
+ let id: QuotaGroupId = resolveQuotaGroup(displayNameRaw);
689
+
690
+ // displayName containing "Gemini Models" => generic gemini
691
+ // the bounded gap window keeps the claude→gpt scan linear on adversarial
692
+ // display names (the code-scanning redos law) — a real grouped name
693
+ // never carries a 128-char canyon between the two markers.
694
+ if (/gemini models/i.test(displayNameRaw)) id = "gemini";
695
+ if (/claude.{0,128}gpt|3p/i.test(displayNameRaw)) id = "claude-3p";
696
+
697
+ const description = String(raw.description ?? "").trim();
698
+ const bucketsRaw = Array.isArray(raw.buckets) ? raw.buckets : [];
699
+
700
+ const buckets = bucketsRaw
701
+ .map((b) => parseQuotaBucket(b, source))
702
+ .sort((a, b) => {
703
+ // Ordena: 5h primeiro, depois weekly
704
+ if (a.window === "5h" && b.window !== "5h") return -1;
705
+ if (b.window === "5h" && a.window !== "5h") return 1;
706
+ return 0;
707
+ });
708
+
709
+ let weeklyRemaining: number | null = null;
710
+ let fiveHourRemaining: number | null = null;
711
+ let minRemaining = 1.0;
712
+ let minBucket: QuotaBucketParsed | null = null;
713
+
714
+ for (const bucket of buckets) {
715
+ if (bucket.window === "weekly" || bucket.bucketId.includes("weekly")) {
716
+ if (weeklyRemaining == null || bucket.remainingFraction < weeklyRemaining)
717
+ weeklyRemaining = bucket.remainingFraction;
718
+ }
719
+ if (bucket.window === "5h" || bucket.bucketId.includes("5h")) {
720
+ if (fiveHourRemaining == null || bucket.remainingFraction < fiveHourRemaining)
721
+ fiveHourRemaining = bucket.remainingFraction;
722
+ }
723
+ if (bucket.remainingFraction < minRemaining) {
724
+ minRemaining = bucket.remainingFraction;
725
+ minBucket = bucket;
726
+ }
727
+ }
728
+
729
+ if (buckets.length === 0) {
730
+ minRemaining = 1.0;
731
+ }
732
+
733
+ const isExhausted = buckets.some((b) => b.isExhausted) || minRemaining <= 0.02;
734
+ const isSoftExhausted = buckets.some((b) => b.isSoftExhausted) || minRemaining <= QUOTA_SOFT_REMAINING;
735
+
736
+ return {
737
+ id,
738
+ displayName: displayNameRaw,
739
+ description,
740
+ rawDisplayName: displayNameRaw,
741
+ source,
742
+ buckets,
743
+ weeklyRemaining,
744
+ fiveHourRemaining,
745
+ minRemainingFraction: minRemaining,
746
+ minBucket,
747
+ isExhausted,
748
+ isSoftExhausted,
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Parse principal do payload v1internal:retrieveUserQuotaSummary
754
+ */
755
+ export function parseQuotaResponse(
756
+ raw: QuotaSummaryRaw,
757
+ opts?: { source?: QuotaSource; projectId?: string; fetchedAt?: number; baseTtlMs?: number },
758
+ ): QuotaSummaryParsed {
759
+ const source = opts?.source ?? "antigravity";
760
+ const projectId = opts?.projectId ?? PROJECT_FALLBACK;
761
+ const fetchedAt = opts?.fetchedAt ?? nowMs();
762
+ const baseTtl = opts?.baseTtlMs ?? QUOTA_CACHE_TTL_MS_DEFAULT;
763
+
764
+ // Normaliza grupos: pode vir em groups, quotaGroup, ou root buckets
765
+ let rawGroups: QuotaGroupRaw[] = [];
766
+ if (Array.isArray(raw.groups)) rawGroups = raw.groups;
767
+ else if (Array.isArray((raw as any).quotaGroup)) rawGroups = (raw as any).quotaGroup;
768
+ else if (Array.isArray(raw.buckets)) {
769
+ // Wrap isolated buckets into one generic Gemini group
770
+ rawGroups = [
771
+ {
772
+ displayName: "Gemini Models",
773
+ description: "Fallback grouped from root buckets",
774
+ buckets: raw.buckets,
775
+ },
776
+ ];
777
+ }
778
+
779
+ const groups = rawGroups
780
+ .map((g) => parseQuotaGroup(g, source))
781
+ .filter((g) => g.buckets.length > 0 || g.displayName !== "Unknown");
782
+
783
+ // When the API returned empty (cloudcode-pa bug), build a healthy fallback group so requests are not blocked
784
+ if (groups.length === 0) {
785
+ // silent, but builds a structure that keeps allowed=true
786
+ const fallbackGroup: QuotaGroupParsed = {
787
+ id: "gemini",
788
+ displayName: "Gemini Models",
789
+ description: "Fallback empty response - assuming full quota (cloudcode-pa returns 1.0)",
790
+ rawDisplayName: "Gemini Models",
791
+ source,
792
+ buckets: [
793
+ {
794
+ bucketId: "gemini-weekly",
795
+ displayName: "Weekly Limit",
796
+ window: "weekly",
797
+ resetTime: null,
798
+ resetTimeMs: null,
799
+ remainingFraction: 1.0,
800
+ remainingPercent: 100,
801
+ isExhausted: false,
802
+ isSoftExhausted: false,
803
+ description: "Fallback - API returned empty",
804
+ },
805
+ ],
806
+ weeklyRemaining: 1.0,
807
+ fiveHourRemaining: 1.0,
808
+ minRemainingFraction: 1.0,
809
+ minBucket: null,
810
+ isExhausted: false,
811
+ isSoftExhausted: false,
812
+ };
813
+ // Point minBucket at the single bucket
814
+ fallbackGroup.minBucket = fallbackGroup.buckets[0] ?? null;
815
+
816
+ const ttl = computeAdaptiveTtlMs({ groups: [fallbackGroup] } as any, baseTtl);
817
+
818
+ return {
819
+ groups: [fallbackGroup],
820
+ fetchedAt,
821
+ expiresAt: fetchedAt + ttl,
822
+ ttlMs: ttl,
823
+ source,
824
+ projectId,
825
+ globalMinRemaining: 1.0,
826
+ isAnyExhausted: false,
827
+ isAnySoftExhausted: false,
828
+ };
829
+ }
830
+
831
+ let globalMin = 1.0;
832
+ let anyExhausted = false;
833
+ let anySoft = false;
834
+
835
+ for (const g of groups) {
836
+ if (g.minRemainingFraction < globalMin) globalMin = g.minRemainingFraction;
837
+ if (g.isExhausted) anyExhausted = true;
838
+ if (g.isSoftExhausted) anySoft = true;
839
+ }
840
+
841
+ const ttl = computeAdaptiveTtlMs({ groups } as any, baseTtl);
842
+
843
+ return {
844
+ groups,
845
+ fetchedAt,
846
+ expiresAt: fetchedAt + ttl,
847
+ ttlMs: ttl,
848
+ source,
849
+ projectId,
850
+ globalMinRemaining: globalMin,
851
+ isAnyExhausted: anyExhausted,
852
+ isAnySoftExhausted: anySoft,
853
+ };
854
+ }
855
+
856
+ // =============================================================================
857
+ // FETCH QUOTA - retrieveUserQuotaSummary
858
+ // =============================================================================
859
+
860
+ export interface RetrieveQuotaOptions {
861
+ source?: QuotaSource; // default antigravity (daily endpoint = quota real)
862
+ timeoutMs?: number;
863
+ retries?: number;
864
+ baseTtlMs?: number;
865
+ signal?: AbortSignal;
866
+ }
867
+
868
+ export class QuotaFetchError extends Error {
869
+ public status?: number | undefined;
870
+ public body?: string | undefined;
871
+ public source: QuotaSource;
872
+ constructor(message: string, source: QuotaSource, status?: number, body?: string) {
873
+ super(message);
874
+ this.name = "QuotaFetchError";
875
+ this.status = status;
876
+ this.body = body;
877
+ this.source = source;
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Direct call to the v1internal:retrieveUserQuotaSummary endpoint.
883
+ * POST {"project": "<projectId>"}
884
+ *
885
+ * @param token - OAuth Bearer access_token
886
+ * @param projectId - cloudaicompanionProject id, fallback rising-fact-p41fc
887
+ * @param opts - source antigravity|gemini-cli, timeout, retries
888
+ */
889
+ export async function retrieveUserQuotaSummary(
890
+ token: string,
891
+ projectId: string = PROJECT_FALLBACK,
892
+ opts?: RetrieveQuotaOptions,
893
+ ): Promise<QuotaSummaryParsed> {
894
+ if (!token || typeof token !== "string" || token.trim().length < 10) {
895
+ throw new TypeError("retrieveUserQuotaSummary: invalid token");
896
+ }
897
+
898
+ const source = opts?.source ?? "antigravity";
899
+ const timeoutMs = opts?.timeoutMs ?? QUOTA_REQUEST_TIMEOUT_MS;
900
+ const retries = opts?.retries ?? 1;
901
+ const baseTtlMs = opts?.baseTtlMs ?? QUOTA_CACHE_TTL_MS_DEFAULT;
902
+
903
+ const endpoint = getEndpointForSource(source);
904
+ const sanitizedProject = (projectId ?? PROJECT_FALLBACK).trim() || PROJECT_FALLBACK;
905
+
906
+ const headers = buildQuotaHeaders(token, source);
907
+ const body = JSON.stringify({ project: sanitizedProject });
908
+
909
+ let lastError: unknown = null;
910
+
911
+ for (let attempt = 0; attempt <= retries; attempt++) {
912
+ const controller = new AbortController();
913
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
914
+
915
+ // If the caller passed an external signal, propagate its abort
916
+ let externalAbortListener: (() => void) | null = null;
917
+ if (opts?.signal) {
918
+ if (opts.signal.aborted) {
919
+ clearTimeout(timer);
920
+ throw new QuotaFetchError("AbortError: external signal aborted", source, 0);
921
+ }
922
+ externalAbortListener = () => controller.abort();
923
+ opts.signal.addEventListener("abort", externalAbortListener, { once: true });
924
+ }
925
+
926
+ try {
927
+ const res = await fetch(endpoint, {
928
+ method: "POST",
929
+ headers,
930
+ body,
931
+ signal: controller.signal,
932
+ } as RequestInit);
933
+
934
+ clearTimeout(timer);
935
+ if (externalAbortListener && opts?.signal) {
936
+ opts.signal.removeEventListener("abort", externalAbortListener);
937
+ }
938
+
939
+ if (!res.ok) {
940
+ const text = await res.text().catch(() => "");
941
+ // A 403 on cloudcode-pa is expected for retrieveUserQuotaSummary under local-only OAuth.
942
+ // Try fallback? If antigravity fails, try gemini-cli? No - keep the error for the caller to decide.
943
+ throw new QuotaFetchError(
944
+ `Quota fetch failed ${res.status} ${res.statusText} on ${source}: ${text.slice(0, 500)}`,
945
+ source,
946
+ res.status,
947
+ text,
948
+ );
949
+ }
950
+
951
+ const json = (await res.json().catch(async () => {
952
+ const txt = await res.text().catch(() => "");
953
+ if (txt) {
954
+ try {
955
+ return JSON.parse(txt);
956
+ } catch {
957
+ // ignored
958
+ }
959
+ }
960
+ return {} as QuotaSummaryRaw;
961
+ })) as QuotaSummaryRaw;
962
+
963
+ const parsed = parseQuotaResponse(json, {
964
+ source,
965
+ projectId: sanitizedProject,
966
+ fetchedAt: nowMs(),
967
+ baseTtlMs,
968
+ });
969
+
970
+ return parsed;
971
+ } catch (err) {
972
+ clearTimeout(timer);
973
+ if (externalAbortListener && opts?.signal) {
974
+ opts.signal.removeEventListener("abort", externalAbortListener);
975
+ }
976
+
977
+ lastError = err;
978
+
979
+ // No retry on 401/403/400
980
+ if (err instanceof QuotaFetchError) {
981
+ if (err.status === 401 || err.status === 403 || err.status === 400) {
982
+ throw err;
983
+ }
984
+ }
985
+
986
+ // Timeout / network - retry while attempts remain
987
+ if (attempt < retries) {
988
+ const backoff = 200 * Math.pow(2, attempt) + Math.random() * 200;
989
+ await new Promise((r) => setTimeout(r, backoff));
990
+ continue;
991
+ }
992
+
993
+ // Attempts exhausted
994
+ if (err instanceof QuotaFetchError) throw err;
995
+ throw new QuotaFetchError(`Quota fetch network error on ${source}: ${(err as Error).message}`, source, 0);
996
+ }
997
+ }
998
+
999
+ throw lastError instanceof Error
1000
+ ? lastError
1001
+ : new QuotaFetchError(`Unknown quota fetch error on ${source}`, source, 0);
1002
+ }
1003
+
1004
+ /**
1005
+ * Dual fetch: tries antigravity (daily) first, then gemini-cli as fallback.
1006
+ * Returns the merged parsed summary with the primary source.
1007
+ */
1008
+ export async function retrieveDualQuota(
1009
+ token: string,
1010
+ projectId: string = PROJECT_FALLBACK,
1011
+ opts?: RetrieveQuotaOptions,
1012
+ ): Promise<{
1013
+ antigravity: QuotaSummaryParsed | null;
1014
+ geminiCli: QuotaSummaryParsed | null;
1015
+ primary: QuotaSummaryParsed;
1016
+ }> {
1017
+ // antigravity deve ser primary (quota real)
1018
+ let antigravity: QuotaSummaryParsed | null = null;
1019
+ let geminiCli: QuotaSummaryParsed | null = null;
1020
+
1021
+ // tenta ambos em paralelo, mas antigravity tem prioridade
1022
+ const results = await Promise.allSettled([
1023
+ retrieveUserQuotaSummary(token, projectId, { ...opts, source: "antigravity" }),
1024
+ retrieveUserQuotaSummary(token, projectId, { ...opts, source: "gemini-cli" }),
1025
+ ]);
1026
+
1027
+ if (results[0].status === "fulfilled") antigravity = results[0].value;
1028
+ if (results[1].status === "fulfilled") geminiCli = results[1].value;
1029
+
1030
+ const primary = antigravity ?? geminiCli;
1031
+
1032
+ if (!primary) {
1033
+ // both failed - rethrow the antigravity error when available
1034
+ const err0 = results[0].status === "rejected" ? results[0].reason : null;
1035
+ const err1 = results[1].status === "rejected" ? results[1].reason : null;
1036
+ throw err0 ?? err1 ?? new QuotaFetchError("Both quota sources failed", "antigravity", 0);
1037
+ }
1038
+
1039
+ return { antigravity, geminiCli, primary };
1040
+ }
1041
+
1042
+ // =============================================================================
1043
+ // CACHE MANAGER - ADAPTIVE TTL + FILE PERSISTENCE
1044
+ // =============================================================================
1045
+
1046
+ interface FileCachePayload {
1047
+ version: 1;
1048
+ entries: Record<string, QuotaCacheEntry>; // key = tokenHash+project+source
1049
+ savedAt: number;
1050
+ }
1051
+
1052
+ function makeCacheKey(token: string, projectId: string, source: QuotaSource): string {
1053
+ const h = hashToken(token);
1054
+ const proj = projectId || PROJECT_FALLBACK;
1055
+ return `${h}:${proj}:${source}`;
1056
+ }
1057
+
1058
+ function loadFileCacheSync(): Record<string, QuotaCacheEntry> {
1059
+ try {
1060
+ if (!fs.existsSync(QUOTA_CACHE_FILE)) return {};
1061
+ const raw = fs.readFileSync(QUOTA_CACHE_FILE, "utf8");
1062
+ const parsed = JSON.parse(raw) as FileCachePayload;
1063
+ if (parsed.version !== 1 || !parsed.entries) return {};
1064
+ // Drop entries expired long ago (> 24h)
1065
+ const now = nowMs();
1066
+ const out: Record<string, QuotaCacheEntry> = {};
1067
+ for (const [k, v] of Object.entries(parsed.entries)) {
1068
+ if (v.expiresAt && v.expiresAt > now - 24 * 60 * 60 * 1000) {
1069
+ out[k] = v;
1070
+ }
1071
+ }
1072
+ return out;
1073
+ } catch {
1074
+ return {};
1075
+ }
1076
+ }
1077
+
1078
+ function saveFileCacheSync(entries: Record<string, QuotaCacheEntry>): void {
1079
+ try {
1080
+ const dir = path.dirname(QUOTA_CACHE_FILE);
1081
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1082
+ const payload: FileCachePayload = {
1083
+ version: 1,
1084
+ entries,
1085
+ savedAt: nowMs(),
1086
+ };
1087
+ const tmp = QUOTA_CACHE_FILE + ".tmp." + crypto.randomBytes(4).toString("hex");
1088
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
1089
+ try {
1090
+ fs.chmodSync(tmp, 0o600);
1091
+ } catch {
1092
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1093
+ }
1094
+ fs.renameSync(tmp, QUOTA_CACHE_FILE);
1095
+ } catch {
1096
+ // silence cache errors
1097
+ }
1098
+ }
1099
+
1100
+ let _memoryFileCache: Record<string, QuotaCacheEntry> | null = null;
1101
+ let _inflightFetches: Map<string, Promise<QuotaSummaryParsed>> = new Map();
1102
+
1103
+ function getFileCache(): Record<string, QuotaCacheEntry> {
1104
+ if (_memoryFileCache) return _memoryFileCache;
1105
+ _memoryFileCache = loadFileCacheSync();
1106
+ return _memoryFileCache;
1107
+ }
1108
+
1109
+ export class QuotaCacheManager {
1110
+ private memory: Map<string, QuotaCacheEntry> = new Map();
1111
+ private requestCount: number = 0;
1112
+ private lastRefreshAt: number = 0;
1113
+ private refreshIntervalMs: number;
1114
+ public softThreshold: number; // remaining threshold
1115
+
1116
+ constructor(opts?: { refreshIntervalMinutes?: number; softThreshold?: number }) {
1117
+ this.refreshIntervalMs = getQuotaRefreshIntervalMs(opts?.refreshIntervalMinutes);
1118
+ this.softThreshold = opts?.softThreshold ?? QUOTA_SOFT_REMAINING; // 0.1 remaining = 90% usado
1119
+ // preload file cache into memory
1120
+ const file = getFileCache();
1121
+ for (const [k, v] of Object.entries(file)) {
1122
+ this.memory.set(k, v);
1123
+ }
1124
+ }
1125
+
1126
+ private getKey(token: string, projectId: string, source: QuotaSource): string {
1127
+ return makeCacheKey(token, projectId, source);
1128
+ }
1129
+
1130
+ getCached(token: string, projectId: string, source: QuotaSource = "antigravity"): QuotaSummaryParsed | null {
1131
+ const key = this.getKey(token, projectId, source);
1132
+ const entry = this.memory.get(key);
1133
+ if (!entry) return null;
1134
+
1135
+ if (nowMs() > entry.expiresAt) {
1136
+ // expired, but still serve stale within a grace window (50% ttl)
1137
+ const grace = entry.ttlMs * 0.5;
1138
+ if (nowMs() > entry.expiresAt + grace) {
1139
+ this.memory.delete(key);
1140
+ return null;
1141
+ }
1142
+ // stale-while-revalidate
1143
+ return entry.summary;
1144
+ }
1145
+
1146
+ return entry.summary;
1147
+ }
1148
+
1149
+ isCacheExpired(token: string, projectId: string, source: QuotaSource = "antigravity"): boolean {
1150
+ const key = this.getKey(token, projectId, source);
1151
+ const entry = this.memory.get(key);
1152
+ if (!entry) return true;
1153
+ return nowMs() > entry.expiresAt;
1154
+ }
1155
+
1156
+ setCached(
1157
+ token: string,
1158
+ projectId: string,
1159
+ source: QuotaSource,
1160
+ raw: QuotaSummaryRaw,
1161
+ parsed: QuotaSummaryParsed,
1162
+ ): void {
1163
+ const key = this.getKey(token, projectId, source);
1164
+ const ttl = parsed.ttlMs ?? computeAdaptiveTtlMs(parsed, QUOTA_CACHE_TTL_MS_DEFAULT);
1165
+
1166
+ const entry: QuotaCacheEntry = {
1167
+ summary: { ...parsed, ttlMs: ttl, expiresAt: nowMs() + ttl },
1168
+ raw,
1169
+ tokenHash: hashToken(token),
1170
+ cachedAt: nowMs(),
1171
+ expiresAt: nowMs() + ttl,
1172
+ ttlMs: ttl,
1173
+ };
1174
+
1175
+ this.memory.set(key, entry);
1176
+
1177
+ // persist
1178
+ const fileCache = getFileCache();
1179
+ fileCache[key] = entry;
1180
+ // cap size (max 50 entries)
1181
+ const keys = Object.keys(fileCache);
1182
+ if (keys.length > 50) {
1183
+ // drop oldest
1184
+ const sorted = keys.sort((a, b) => (fileCache[a]?.cachedAt ?? 0) - (fileCache[b]?.cachedAt ?? 0));
1185
+ for (let i = 0; i < sorted.length - 50; i++) delete fileCache[sorted[i]!];
1186
+ }
1187
+ saveFileCacheSync(fileCache);
1188
+ _memoryFileCache = fileCache;
1189
+ }
1190
+
1191
+ /**
1192
+ * Clears the cache for a token/project or everything.
1193
+ */
1194
+ clear(token?: string, projectId?: string, source?: QuotaSource): void {
1195
+ if (!token) {
1196
+ this.memory.clear();
1197
+ try {
1198
+ fs.unlinkSync(QUOTA_CACHE_FILE);
1199
+ } catch {
1200
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1201
+ }
1202
+ _memoryFileCache = {};
1203
+ return;
1204
+ }
1205
+ const targetProject = projectId ?? PROJECT_FALLBACK;
1206
+ if (source) {
1207
+ const key = this.getKey(token, targetProject, source);
1208
+ this.memory.delete(key);
1209
+ const fc = getFileCache();
1210
+ delete fc[key];
1211
+ saveFileCacheSync(fc);
1212
+ } else {
1213
+ // clear both sources
1214
+ for (const src of ["antigravity", "gemini-cli"] as QuotaSource[]) {
1215
+ const key = this.getKey(token, targetProject, src);
1216
+ this.memory.delete(key);
1217
+ const fc = getFileCache();
1218
+ delete fc[key];
1219
+ saveFileCacheSync(fc);
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ /**
1225
+ * Background refresh without blocking the caller.
1226
+ * Deduplicates in-flight requests per key.
1227
+ */
1228
+ refreshInBackground(token: string, projectId: string, source: QuotaSource = "antigravity"): void {
1229
+ const key = this.getKey(token, projectId, source);
1230
+
1231
+ if (_inflightFetches.has(key)) return; // already in progress
1232
+
1233
+ const p = retrieveUserQuotaSummary(token, projectId, { source })
1234
+ .then((parsed) => {
1235
+ // empty raw fallback because background has no original raw
1236
+ this.setCached(token, projectId, source, { groups: [] }, parsed);
1237
+ this.lastRefreshAt = nowMs();
1238
+ return parsed;
1239
+ })
1240
+ .catch(() => {
1241
+ // silencia erro background
1242
+ return null as any;
1243
+ })
1244
+ .finally(() => {
1245
+ _inflightFetches.delete(key);
1246
+ });
1247
+
1248
+ _inflightFetches.set(key, p);
1249
+
1250
+ // Non-blocking: fire-and-forget, but avoids unhandled rejections
1251
+ p.catch(() => {});
1252
+ }
1253
+
1254
+ /**
1255
+ * Called after each successful request to decide whether to schedule a refresh.
1256
+ */
1257
+ trackRequestAndMaybeRefresh(token: string, projectId: string, source: QuotaSource = "antigravity"): void {
1258
+ this.requestCount++;
1259
+ const now = nowMs();
1260
+ const sinceLast = now - this.lastRefreshAt;
1261
+
1262
+ // Refresh interval elapsed -> schedule background refresh
1263
+ if (sinceLast > this.refreshIntervalMs) {
1264
+ this.refreshInBackground(token, projectId, source);
1265
+ return;
1266
+ }
1267
+
1268
+ // Heuristic: low remaining requires more frequent refreshes
1269
+ const cached = this.getCached(token, projectId, source);
1270
+ if (cached && cached.globalMinRemaining < 0.3) {
1271
+ // Remaining <30% and 2min elapsed -> refresh
1272
+ if (sinceLast > QUOTA_CACHE_TTL_MIN_MS) {
1273
+ this.refreshInBackground(token, projectId, source);
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ getStats(): { size: number; requestCount: number; lastRefreshAt: number; refreshIntervalMs: number } {
1279
+ return {
1280
+ size: this.memory.size,
1281
+ requestCount: this.requestCount,
1282
+ lastRefreshAt: this.lastRefreshAt,
1283
+ refreshIntervalMs: this.refreshIntervalMs,
1284
+ };
1285
+ }
1286
+ }
1287
+
1288
+ // Singleton default
1289
+ let _defaultCacheManager: QuotaCacheManager | null = null;
1290
+
1291
+ export function getQuotaCacheManager(opts?: { refreshIntervalMinutes?: number | undefined }): QuotaCacheManager {
1292
+ if (!_defaultCacheManager) {
1293
+ _defaultCacheManager = new QuotaCacheManager({
1294
+ refreshIntervalMinutes: opts?.refreshIntervalMinutes ?? getQuotaRefreshIntervalMs() / 60000,
1295
+ });
1296
+ }
1297
+ return _defaultCacheManager;
1298
+ }
1299
+
1300
+ // =============================================================================
1301
+ // CORE QUOTA CHECK APIs
1302
+ // =============================================================================
1303
+
1304
+ /**
1305
+ * Checks quota for a specific group.
1306
+ * Returns an allowed boolean plus metadata.
1307
+ *
1308
+ * @param token - access token
1309
+ * @param projectId - project id
1310
+ * @param group - group id ou model string (ex: "gemini-3-pro" ou "antigravity-gemini-3-pro")
1311
+ * @param opts - source, softThreshold override, useCache
1312
+ */
1313
+ export async function checkQuota(
1314
+ token: string,
1315
+ projectId: string = PROJECT_FALLBACK,
1316
+ group?: string | null,
1317
+ opts?: {
1318
+ source?: QuotaSource;
1319
+ softThreshold?: number; // remaining threshold (default 0.1)
1320
+ useCache?: boolean;
1321
+ refreshIntervalMinutes?: number | undefined;
1322
+ },
1323
+ ): Promise<CheckQuotaResult> {
1324
+ const source = opts?.source ?? "antigravity";
1325
+ const softRemaining = opts?.softThreshold ?? QUOTA_SOFT_REMAINING;
1326
+ const useCache = opts?.useCache ?? true;
1327
+
1328
+ const cacheMgr = getQuotaCacheManager({ refreshIntervalMinutes: opts?.refreshIntervalMinutes });
1329
+
1330
+ let summary: QuotaSummaryParsed | null = null;
1331
+
1332
+ if (useCache) {
1333
+ summary = cacheMgr.getCached(token, projectId, source);
1334
+ }
1335
+
1336
+ if (!summary) {
1337
+ try {
1338
+ summary = await retrieveUserQuotaSummary(token, projectId, { source });
1339
+ cacheMgr.setCached(token, projectId, source, { groups: [] }, summary);
1340
+ } catch (e) {
1341
+ // Se falha fetch mas tem cache expirado stale, usa stale como fallback permissivo
1342
+ const staleKey = makeCacheKey(token, projectId, source);
1343
+ const staleEntry = (getFileCache()[staleKey] ?? null) as QuotaCacheEntry | null;
1344
+ if (staleEntry?.summary) {
1345
+ summary = staleEntry.summary;
1346
+ } else {
1347
+ throw e;
1348
+ }
1349
+ }
1350
+ } else {
1351
+ // Expired cache -> trigger a non-blocking background refresh
1352
+ if (cacheMgr.isCacheExpired(token, projectId, source)) {
1353
+ cacheMgr.refreshInBackground(token, projectId, source);
1354
+ }
1355
+ // track request for a potential refresh
1356
+ cacheMgr.trackRequestAndMaybeRefresh(token, projectId, source);
1357
+ }
1358
+
1359
+ if (!summary) {
1360
+ // fallback final: allow
1361
+ return {
1362
+ allowed: true,
1363
+ groupId: null,
1364
+ bucketId: null,
1365
+ remainingFraction: 1,
1366
+ remainingPercent: 100,
1367
+ resetTime: null,
1368
+ resetTimeMs: null,
1369
+ reason: "no quota data, assuming allowed",
1370
+ isSoftThreshold: false,
1371
+ isHardExhausted: false,
1372
+ shouldSkipAccount: false,
1373
+ source,
1374
+ };
1375
+ }
1376
+
1377
+ // Resolve target group
1378
+ let targetGroups: QuotaGroupParsed[] = summary.groups;
1379
+
1380
+ if (group) {
1381
+ const gid = resolveQuotaGroup(group);
1382
+ if (gid !== "unknown") {
1383
+ // With a specific group, filter by exact id or generic mapping
1384
+ if (gid === "gemini") {
1385
+ // generic gemini => pick the Gemini Models group when present, else every gemini-*
1386
+ const geminiModelsGroup = summary.groups.find((g) => g.id === "gemini" || /gemini models/i.test(g.displayName));
1387
+ if (geminiModelsGroup) targetGroups = [geminiModelsGroup];
1388
+ else targetGroups = summary.groups.filter((g) => g.id.startsWith("gemini"));
1389
+ } else if (gid === "claude-3p") {
1390
+ const claudeGroup = summary.groups.find((g) => g.id === "claude-3p" || /claude.*gpt|3p/i.test(g.displayName));
1391
+ if (claudeGroup) targetGroups = [claudeGroup];
1392
+ else targetGroups = summary.groups.filter((g) => g.id.startsWith("claude"));
1393
+ } else {
1394
+ // try exact match
1395
+ const exact = summary.groups.filter((g) => g.id === gid);
1396
+ if (exact.length > 0) targetGroups = exact;
1397
+ else {
1398
+ // Without an exact API mapping (the API returns generic groups), use the global minRemaining but keep gid in the result
1399
+ // target stays as all groups since the API does not split sub-models
1400
+ targetGroups = summary.groups;
1401
+ }
1402
+ }
1403
+ } else {
1404
+ // unknown group -> use all groups
1405
+ targetGroups = summary.groups;
1406
+ }
1407
+ }
1408
+
1409
+ // Worst case across the target groups
1410
+ let minRemaining = 1.0;
1411
+ let minBucket: QuotaBucketParsed | null = null;
1412
+ let minGroup: QuotaGroupParsed | null = null;
1413
+
1414
+ for (const g of targetGroups) {
1415
+ if (g.minRemainingFraction < minRemaining) {
1416
+ minRemaining = g.minRemainingFraction;
1417
+ minBucket = g.minBucket ?? g.buckets[0] ?? null;
1418
+ minGroup = g;
1419
+ }
1420
+ }
1421
+
1422
+ if (targetGroups.length === 0) {
1423
+ // No target groups - fall back to allow
1424
+ return {
1425
+ allowed: true,
1426
+ groupId: group ? resolveQuotaGroup(group) : null,
1427
+ bucketId: null,
1428
+ remainingFraction: 1,
1429
+ remainingPercent: 100,
1430
+ resetTime: null,
1431
+ resetTimeMs: null,
1432
+ reason: "no matching groups, assuming allowed",
1433
+ isSoftThreshold: false,
1434
+ isHardExhausted: false,
1435
+ shouldSkipAccount: false,
1436
+ source,
1437
+ };
1438
+ }
1439
+
1440
+ const isHardExhausted = minRemaining <= 0.02 || minRemaining <= 1 - QUOTA_HARD_THRESHOLD;
1441
+ const isSoftThreshold = minRemaining <= softRemaining;
1442
+
1443
+ const shouldSkip = isHardExhausted || (isSoftThreshold && minRemaining <= softRemaining);
1444
+
1445
+ // allowed=false only on hard exhaustion; soft allows but flags shouldSkip for the load balancer
1446
+ const allowed = !isHardExhausted;
1447
+
1448
+ const gidResult = group ? resolveQuotaGroup(group) : (minGroup?.id ?? null);
1449
+
1450
+ return {
1451
+ allowed,
1452
+ groupId: gidResult,
1453
+ bucketId: minBucket?.bucketId ?? null,
1454
+ remainingFraction: minRemaining,
1455
+ remainingPercent: Math.round(minRemaining * 10000) / 100,
1456
+ resetTime: minBucket?.resetTime ?? null,
1457
+ resetTimeMs: minBucket?.resetTimeMs ?? null,
1458
+ reason: isHardExhausted
1459
+ ? `quota exhausted (${(minRemaining * 100).toFixed(2)}% remaining, bucket ${minBucket?.bucketId})`
1460
+ : isSoftThreshold
1461
+ ? `soft threshold breached (90% used, ${Math.round(minRemaining * 100)}% remaining)`
1462
+ : `quota ok (${Math.round(minRemaining * 100)}% remaining)`,
1463
+ isSoftThreshold,
1464
+ isHardExhausted,
1465
+ shouldSkipAccount: shouldSkip,
1466
+ source,
1467
+ group: minGroup ?? undefined,
1468
+ bucket: minBucket ?? undefined,
1469
+ };
1470
+ }
1471
+
1472
+ /**
1473
+ * Reports whether quota is exhausted (hard threshold).
1474
+ * Thin wrapper over checkQuota kept for compatibility.
1475
+ */
1476
+ export async function isQuotaExhausted(
1477
+ token: string,
1478
+ projectId: string = PROJECT_FALLBACK,
1479
+ group?: string | null,
1480
+ opts?: { source?: QuotaSource; useCache?: boolean },
1481
+ ): Promise<boolean> {
1482
+ const result = await checkQuota(token, projectId, group, opts);
1483
+ return result.isHardExhausted;
1484
+ }
1485
+
1486
+ /**
1487
+ * Decides whether the account should be skipped (soft 90% threshold).
1488
+ * Used during multi-account rotation.
1489
+ */
1490
+ export async function shouldSkipAccount(
1491
+ token: string,
1492
+ projectId: string = PROJECT_FALLBACK,
1493
+ group?: string | null,
1494
+ opts?: { source?: QuotaSource; softThreshold?: number; useCache?: boolean },
1495
+ ): Promise<boolean> {
1496
+ const result = await checkQuota(token, projectId, group, opts);
1497
+ return result.shouldSkipAccount;
1498
+ }
1499
+
1500
+ /**
1501
+ * Lists the current parsed groups (cache first, otherwise fetch).
1502
+ */
1503
+ export async function getQuotaGroups(
1504
+ token: string,
1505
+ projectId: string = PROJECT_FALLBACK,
1506
+ opts?: { source?: QuotaSource; useCache?: boolean },
1507
+ ): Promise<QuotaGroupParsed[]> {
1508
+ const source = opts?.source ?? "antigravity";
1509
+ const useCache = opts?.useCache ?? true;
1510
+
1511
+ const cacheMgr = getQuotaCacheManager();
1512
+
1513
+ if (useCache) {
1514
+ const cached = cacheMgr.getCached(token, projectId, source);
1515
+ if (cached) return cached.groups;
1516
+ }
1517
+
1518
+ const summary = await retrieveUserQuotaSummary(token, projectId, { source });
1519
+ cacheMgr.setCached(token, projectId, source, { groups: [] }, summary);
1520
+ return summary.groups;
1521
+ }
1522
+
1523
+ // =============================================================================
1524
+ // QUOTA MANAGER CLASS - DUAL QUOTA + BACKGROUND REFRESH
1525
+ // =============================================================================
1526
+
1527
+ export interface QuotaManagerOptions {
1528
+ refreshIntervalMinutes?: number;
1529
+ softThreshold?: number; // remaining fraction (default 0.1)
1530
+ hardThreshold?: number; // remaining fraction (default 0.02)
1531
+ projectId?: string;
1532
+ source?: QuotaSource; // default antigravity
1533
+ enableDual?: boolean; // when true, fetches both antigravity+gemini-cli and merges
1534
+ }
1535
+
1536
+ export class QuotaManager {
1537
+ public readonly projectId: string;
1538
+ public readonly source: QuotaSource;
1539
+ public readonly refreshIntervalMinutes: number;
1540
+ public readonly softThreshold: number; // remaining
1541
+ public readonly hardThreshold: number;
1542
+ public readonly enableDual: boolean;
1543
+
1544
+ private cacheMgr: QuotaCacheManager;
1545
+ private token: string | null = null;
1546
+ private lastGroups: QuotaGroupParsed[] = [];
1547
+
1548
+ constructor(opts?: QuotaManagerOptions) {
1549
+ this.projectId = opts?.projectId ?? PROJECT_FALLBACK;
1550
+ this.source = opts?.source ?? "antigravity";
1551
+ this.refreshIntervalMinutes = opts?.refreshIntervalMinutes ?? QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT;
1552
+ this.softThreshold = opts?.softThreshold ?? QUOTA_SOFT_REMAINING;
1553
+ this.hardThreshold = opts?.hardThreshold ?? 0.02;
1554
+ this.enableDual = opts?.enableDual ?? true;
1555
+
1556
+ this.cacheMgr = getQuotaCacheManager({ refreshIntervalMinutes: this.refreshIntervalMinutes });
1557
+ }
1558
+
1559
+ setToken(token: string): void {
1560
+ if (!token || token.length < 10) throw new TypeError("QuotaManager.setToken: invalid token");
1561
+ this.token = token;
1562
+ }
1563
+
1564
+ getToken(): string | null {
1565
+ return this.token;
1566
+ }
1567
+
1568
+ /**
1569
+ * Forces an immediate refresh (primary + secondary when dual).
1570
+ */
1571
+ async refresh(token?: string, projectId?: string): Promise<QuotaSummaryParsed> {
1572
+ const t = token ?? this.token;
1573
+ if (!t) throw new Error("QuotaManager.refresh: token not set. Call setToken() or pass a token.");
1574
+
1575
+ const pid = projectId ?? this.projectId;
1576
+
1577
+ if (this.enableDual) {
1578
+ const dual = await retrieveDualQuota(t, pid, {
1579
+ source: this.source,
1580
+ baseTtlMs: getQuotaRefreshIntervalMs(this.refreshIntervalMinutes),
1581
+ });
1582
+ // Cache both sources when available
1583
+ if (dual.antigravity) {
1584
+ this.cacheMgr.setCached(t, pid, "antigravity", { groups: [] }, dual.antigravity);
1585
+ }
1586
+ if (dual.geminiCli) {
1587
+ this.cacheMgr.setCached(t, pid, "gemini-cli", { groups: [] }, dual.geminiCli);
1588
+ }
1589
+ this.lastGroups = dual.primary.groups;
1590
+ return dual.primary;
1591
+ } else {
1592
+ const summary = await retrieveUserQuotaSummary(t, pid, {
1593
+ source: this.source,
1594
+ baseTtlMs: getQuotaRefreshIntervalMs(this.refreshIntervalMinutes),
1595
+ });
1596
+ this.cacheMgr.setCached(t, pid, this.source, { groups: [] }, summary);
1597
+ this.lastGroups = summary.groups;
1598
+ return summary;
1599
+ }
1600
+ }
1601
+
1602
+ /**
1603
+ * Non-blocking background refresh.
1604
+ */
1605
+ refreshInBackground(token?: string, projectId?: string, source?: QuotaSource): void {
1606
+ const t = token ?? this.token;
1607
+ if (!t) return;
1608
+ const pid = projectId ?? this.projectId;
1609
+ const src = source ?? this.source;
1610
+ this.cacheMgr.refreshInBackground(t, pid, src);
1611
+
1612
+ if (this.enableDual) {
1613
+ const other: QuotaSource = src === "antigravity" ? "gemini-cli" : "antigravity";
1614
+ this.cacheMgr.refreshInBackground(t, pid, other);
1615
+ }
1616
+ }
1617
+
1618
+ /**
1619
+ * Called after each successful generateContent request.
1620
+ */
1621
+ notifyRequestSuccess(token?: string, projectId?: string): void {
1622
+ const t = token ?? this.token;
1623
+ if (!t) return;
1624
+ this.cacheMgr.trackRequestAndMaybeRefresh(t, projectId ?? this.projectId, this.source);
1625
+ }
1626
+
1627
+ async checkQuota(group?: string | null, token?: string): Promise<CheckQuotaResult> {
1628
+ const t = token ?? this.token;
1629
+ if (!t) throw new Error("QuotaManager.checkQuota: token not set");
1630
+
1631
+ return checkQuota(t, this.projectId, group, {
1632
+ source: this.source,
1633
+ softThreshold: this.softThreshold,
1634
+ refreshIntervalMinutes: this.refreshIntervalMinutes,
1635
+ });
1636
+ }
1637
+
1638
+ async isQuotaExhausted(group?: string | null, token?: string): Promise<boolean> {
1639
+ const t = token ?? this.token;
1640
+ if (!t) throw new Error("QuotaManager.isQuotaExhausted: token not set");
1641
+ return isQuotaExhausted(t, this.projectId, group, { source: this.source });
1642
+ }
1643
+
1644
+ async shouldSkipAccount(group?: string | null, token?: string): Promise<boolean> {
1645
+ const t = token ?? this.token;
1646
+ if (!t) throw new Error("QuotaManager.shouldSkipAccount: token not set");
1647
+ return shouldSkipAccount(t, this.projectId, group, {
1648
+ source: this.source,
1649
+ softThreshold: this.softThreshold,
1650
+ });
1651
+ }
1652
+
1653
+ async getQuotaGroups(token?: string): Promise<QuotaGroupParsed[]> {
1654
+ const t = token ?? this.token;
1655
+ if (!t) throw new Error("QuotaManager.getQuotaGroups: token not set");
1656
+ return getQuotaGroups(t, this.projectId, { source: this.source });
1657
+ }
1658
+
1659
+ resolveQuotaGroup(model: string): QuotaGroupId {
1660
+ return resolveQuotaGroup(model);
1661
+ }
1662
+
1663
+ getQuotaGroup(input: string): ResilientQuotaGroupDef | null {
1664
+ return getQuotaGroup(input);
1665
+ }
1666
+
1667
+ getCacheStats() {
1668
+ return this.cacheMgr.getStats();
1669
+ }
1670
+
1671
+ clearCache(token?: string, source?: QuotaSource): void {
1672
+ if (token) {
1673
+ this.cacheMgr.clear(token, this.projectId, source);
1674
+ } else if (this.token) {
1675
+ this.cacheMgr.clear(this.token, this.projectId, source);
1676
+ } else {
1677
+ this.cacheMgr.clear();
1678
+ }
1679
+ }
1680
+ }
1681
+
1682
+ // =============================================================================
1683
+ // SINGLETON HELPERS + COMPATIBILITY EXPORTS
1684
+ // =============================================================================
1685
+
1686
+ let _defaultQuotaManager: QuotaManager | null = null;
1687
+
1688
+ export function getDefaultQuotaManager(opts?: QuotaManagerOptions): QuotaManager {
1689
+ if (!_defaultQuotaManager) {
1690
+ _defaultQuotaManager = new QuotaManager(opts);
1691
+ }
1692
+ return _defaultQuotaManager;
1693
+ }
1694
+
1695
+ /**
1696
+ * Helper for the opencode plugin: verifies quota before routing a request.
1697
+ * Returns true when the request may proceed.
1698
+ */
1699
+ export async function canProceedWithRequest(
1700
+ token: string,
1701
+ model: string,
1702
+ projectId: string = PROJECT_FALLBACK,
1703
+ opts?: { source?: QuotaSource | undefined; refreshIntervalMinutes?: number | undefined },
1704
+ ): Promise<{ allowed: boolean; check: CheckQuotaResult }> {
1705
+ const group = resolveQuotaGroup(model);
1706
+ const check = await checkQuota(token, projectId, group, {
1707
+ source: opts?.source ?? "antigravity",
1708
+ refreshIntervalMinutes: opts?.refreshIntervalMinutes,
1709
+ });
1710
+ return { allowed: check.allowed, check };
1711
+ }
1712
+
1713
+ // =============================================================================
1714
+ // CONFIG RESOLUTION - quota_refresh_interval_minutes
1715
+ // =============================================================================
1716
+
1717
+ export interface QuotaConfig {
1718
+ quota_refresh_interval_minutes: number;
1719
+ quota_soft_threshold_percent: number; // 90 default
1720
+ quota_hard_threshold_percent: number; // 98 default
1721
+ quota_source: QuotaSource;
1722
+ quota_dual_enabled: boolean;
1723
+ quota_cache_ttl_ms_default: number;
1724
+ project_fallback: string;
1725
+ }
1726
+
1727
+ export const DEFAULT_QUOTA_CONFIG: QuotaConfig = {
1728
+ quota_refresh_interval_minutes: QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT,
1729
+ quota_soft_threshold_percent: 90,
1730
+ quota_hard_threshold_percent: 98,
1731
+ quota_source: "antigravity",
1732
+ quota_dual_enabled: true,
1733
+ quota_cache_ttl_ms_default: QUOTA_CACHE_TTL_MS_DEFAULT,
1734
+ project_fallback: PROJECT_FALLBACK,
1735
+ };
1736
+
1737
+ export function resolveQuotaConfig(partial?: Partial<QuotaConfig>): QuotaConfig {
1738
+ const envMinutes = process.env.QUOTA_REFRESH_INTERVAL_MINUTES ?? process.env.ANTIGRAVITY_QUOTA_REFRESH_MINUTES;
1739
+ let minutes = partial?.quota_refresh_interval_minutes ?? DEFAULT_QUOTA_CONFIG.quota_refresh_interval_minutes;
1740
+ if (envMinutes) {
1741
+ const p = Number.parseInt(envMinutes, 10);
1742
+ if (!Number.isNaN(p) && p > 0) minutes = p;
1743
+ }
1744
+
1745
+ return {
1746
+ quota_refresh_interval_minutes: Math.max(1, Math.min(minutes, 120)),
1747
+ quota_soft_threshold_percent:
1748
+ partial?.quota_soft_threshold_percent ?? DEFAULT_QUOTA_CONFIG.quota_soft_threshold_percent,
1749
+ quota_hard_threshold_percent:
1750
+ partial?.quota_hard_threshold_percent ?? DEFAULT_QUOTA_CONFIG.quota_hard_threshold_percent,
1751
+ quota_source: partial?.quota_source ?? DEFAULT_QUOTA_CONFIG.quota_source,
1752
+ quota_dual_enabled: partial?.quota_dual_enabled ?? DEFAULT_QUOTA_CONFIG.quota_dual_enabled,
1753
+ quota_cache_ttl_ms_default: partial?.quota_cache_ttl_ms_default ?? DEFAULT_QUOTA_CONFIG.quota_cache_ttl_ms_default,
1754
+ project_fallback: partial?.project_fallback ?? DEFAULT_QUOTA_CONFIG.project_fallback,
1755
+ };
1756
+ }
1757
+
1758
+ // =============================================================================
1759
+ // PROVENANCE NOTES - BLINDED CONSTANTS (lineage-B history; the values
1760
+ // themselves are owned by constants.ts / auth.ts / models.ts and re-exported
1761
+ // at the top of this module)
1762
+ // =============================================================================
1763
+
1764
+ /**
1765
+ * Three public OAuth client IDs circulate in public binaries:
1766
+ * - 1071006060591-tmhssin2h21lcre235vtolojh4g403ep... (Antigravity primary, accounts.ts)
1767
+ * - 681255809395-oo8ft2oq6bda7b6t5a2prl1saaq1hhs... (Gemini CLI secondary)
1768
+ * - 681255809395-oo8f…b135j... (Gemini CLI official)
1769
+ * These are public client IDs (not confidential). The client secrets below are
1770
+ * likewise public in those binaries but flagged as blinded for log redaction.
1771
+ */
1772
+ // v2.1.15 Phase C: the REAL env-overridable antigravity-cli client pair is
1773
+ // owned by auth.js (CLIENT_ID/CLIENT_SECRET — same env chain incl. the
1774
+ // GOOGLE_CLIENT_ID fallback). constants.js keeps only the blinded decoy.
1775
+
1776
+ // v2.1.15 Phase C: ANTIGRAVITY_CLIENT_ID_FALLBACK_SECONDARY moved to constants.js.
1777
+
1778
+ // v2.1.15 Phase C: the local env-overridable gemini-cli pair (with the
1779
+ // stale typo'd secret tail Cu5cWa_kRl) was deleted in favor of the
1780
+ // constants.js GEMINI_CLI_OAUTH_CLIENT_ID/SECRET owner (live-validated
1781
+ // secret tail Cu5clXFsxl).
1782
+
1783
+ // ANTIGRAVITY_CLIENT_ID_IS_BLINDED / ANTIGRAVITY_CLIENT_SECRET_IS_BLINDED:
1784
+ // identical `true` flags — imported and re-exported above.
1785
+
1786
+ /** Version fallback (governance keeps 1.19.2 as last known to bypass ban) + floor. */
1787
+ // ANTIGRAVITY_VERSION_FALLBACK / ANTIGRAVITY_USER_AGENT_FALLBACK: identical
1788
+ // values — imported and re-exported above (from constants.ts).
1789
+ // ANTIGRAVITY_VERSION_MIN: identical "1.15.8" — imported and re-exported above (from models.ts).
1790
+
1791
+ // =============================================================================
1792
+ // v2.1.16 dead-code cleanup: the unused resilient lineage-B quota engine
1793
+ // (zero consumers) was deleted — resolveQuotaPoolGroup + its aliases
1794
+ // (resolveGroup, getQuotaGroupForModel, modelToGroup),
1795
+ // parseQuotaResponseResilient, retrieveUserQuotaSummaryResilient,
1796
+ // checkQuotaFromSummary, isQuotaExhaustedBySummary, shouldSkipAccountBySummary,
1797
+ // getAdaptiveTtlMs, resolveBaseTtlFromConfig, parseRateLimitHeaders +
1798
+ // RateLimitInfo, QuotaManagerResilient + getQuotaManager /
1799
+ // resetQuotaManager / fetchQuotaWithCache / refreshQuotaBackground,
1800
+ // resolveCachePath, QUOTA_INFO, the Quota facade, the resilient types
1801
+ // (QuotaPoolStats, QuotaSummary, CheckQuotaResultResilient,
1802
+ // QuotaFileCacheEntry, FileCacheData, QuotaManagerResilientOptions,
1803
+ // RetrieveQuotaOptionsResilient) and the private helpers used only by that
1804
+ // engine (normalizePlatform, normalizeArch, RESILIENT_CASCADE_HOSTS,
1805
+ // RESILIENT_QUOTA_CACHE_TTL_MIN/MAX_MS, FILE_MODE, DIR_MODE,
1806
+ // CACHE_FILE_NAME, ensureDir, ensureDirSync, atomicWriteFileAtomic,
1807
+ // atomicWriteFileSync, buildCacheKey, MODEL_GROUP_MAP, normalizeGroupName,
1808
+ // tryParseNumber, parseDateToMs, tryParseBool, extractPoolStats). Every symbol
1809
+ // was grep-verified to have zero consumers outside quota.ts before deletion.
1810
+ // The live lineage-A engine above is untouched.
1811
+ // =============================================================================
1812
+
1813
+ /**
1814
+ * Cloud Code Assist quota method path — the historical QUOTA_METHOD name
1815
+ * consumed by plugin.ts. v2.1.16 dedup: derived from the constants.js
1816
+ * QUOTA_ENDPOINT owner (itself derived from CODE_ASSIST_PATH_MAP
1817
+ * .RETRIEVE_USER_QUOTA_SUMMARY) instead of a duplicated path literal.
1818
+ */
1819
+ export const QUOTA_METHOD = QUOTA_ENDPOINT;
1820
+
1821
+ /**
1822
+ * SHA-256 token hash truncated to 32 hex chars (FNV-1a fallback) — the cache
1823
+ * key primitive of the live lineage-A file cache (makeCacheKey / setCached).
1824
+ */
1825
+ export function hashToken(token: string): string {
1826
+ try {
1827
+ return createHash("sha256").update(token).digest("hex").slice(0, 32);
1828
+ } catch {
1829
+ return String(fnv1a32(token)).padStart(8, "0");
1830
+ }
1831
+ }
1832
+
1833
+ // =============================================================================
1834
+ // EXPORT DEFAULT
1835
+ // =============================================================================
1836
+
1837
+ const quotaModule = {
1838
+ // canonical constants
1839
+ OAUTH_CLIENT_ID: GEMINI_CLI_OAUTH_CLIENT_ID,
1840
+ OAUTH_CLIENT_SECRET: GEMINI_CLI_OAUTH_CLIENT_SECRET,
1841
+ PROJECT_FALLBACK,
1842
+ ENDPOINTS: API_PATHS,
1843
+ ENDPOINTS_DAILY: API_PATHS_DAILY,
1844
+ QUOTA_ENDPOINTS,
1845
+ QUOTA_METHOD,
1846
+ MODELS_2026: MODELS_2026_LEGACY,
1847
+ QUOTA_GROUP_DEFS,
1848
+ QUOTA_REFRESH_INTERVAL_MINUTES_DEFAULT,
1849
+ QUOTA_SOFT_THRESHOLD,
1850
+ QUOTA_HARD_THRESHOLD,
1851
+
1852
+ // core functions
1853
+ retrieveUserQuotaSummary,
1854
+ retrieveDualQuota,
1855
+ parseQuotaResponse,
1856
+ parseQuotaBucket,
1857
+ parseQuotaGroup,
1858
+ computeAdaptiveTtlMs,
1859
+ getQuotaRefreshIntervalMs,
1860
+
1861
+ // resolution
1862
+ resolveQuotaGroup,
1863
+ getQuotaGroup,
1864
+ listQuotaGroups,
1865
+ isGeminiCLIOnlyModel,
1866
+
1867
+ // checks
1868
+ checkQuota,
1869
+ isQuotaExhausted,
1870
+ shouldSkipAccount,
1871
+ getQuotaGroups,
1872
+ canProceedWithRequest,
1873
+
1874
+ // cache + managers
1875
+ QuotaCacheManager,
1876
+ getQuotaCacheManager,
1877
+ QuotaManager,
1878
+ getDefaultQuotaManager,
1879
+
1880
+ // config
1881
+ DEFAULT_QUOTA_CONFIG,
1882
+ resolveQuotaConfig,
1883
+
1884
+ // headers
1885
+ buildQuotaHeaders,
1886
+
1887
+ // extras
1888
+ hashToken,
1889
+ };
1890
+
1891
+ export default quotaModule;