@centerforagenticai/pi-multi-account 0.1.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,416 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ antigravityHeaders,
4
+ endpointCandidates,
5
+ extractProjectId,
6
+ fetchAvailableModelsCatalog,
7
+ parseApiKey,
8
+ resolveProjectId,
9
+ } from "../client/client.js";
10
+ import {
11
+ setLastEndpoint,
12
+ setLastError,
13
+ setLastProjectId,
14
+ setLastStatus,
15
+ } from "../diagnostics/diagnostics.js";
16
+ import { isRecord } from "../utils/util.js";
17
+ import { safeError } from "../utils/security.js";
18
+ import { antigravityFetch } from "../utils/http.js";
19
+ import type {
20
+ AccountUsage,
21
+ ApiErrorBody,
22
+ AvailableModelsRaw,
23
+ LoadCodeAssistRaw,
24
+ ModelQuotaRow,
25
+ QuotaBucket,
26
+ QuotaGroup,
27
+ QuotaSummaryRaw,
28
+ TierInfo,
29
+ TierRaw,
30
+ } from "../types/types.js";
31
+
32
+ function clampFraction(value: unknown): number | undefined {
33
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
34
+ if (value < 0) return 0;
35
+ if (value > 1) return 1;
36
+ return value;
37
+ }
38
+
39
+ function remainingPercent(remaining?: number): number | undefined {
40
+ if (remaining === undefined) return undefined;
41
+ return Math.round(remaining * 1000) / 10;
42
+ }
43
+
44
+ function progressBar(remaining?: number, width = 20): string {
45
+ if (remaining === undefined) return `[${"?".repeat(width)}]`;
46
+ const filled = Math.max(0, Math.min(width, Math.round(remaining * width)));
47
+ return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
48
+ }
49
+
50
+ function formatReset(resetTime?: string): string {
51
+ if (!resetTime) return "n/a";
52
+ const ts = Date.parse(resetTime);
53
+ if (!Number.isFinite(ts)) return resetTime;
54
+ const delta = ts - Date.now();
55
+ if (delta <= 0) return "now";
56
+ const totalMin = Math.round(delta / 60000);
57
+ const days = Math.floor(totalMin / (60 * 24));
58
+ const hours = Math.floor((totalMin % (60 * 24)) / 60);
59
+ const mins = totalMin % 60;
60
+ if (days > 0) return `${days}d ${hours}h`;
61
+ if (hours > 0) return `${hours}h ${mins}m`;
62
+ return `${mins}m`;
63
+ }
64
+
65
+ function jsonHeaders(token: string): Record<string, string> {
66
+ return {
67
+ ...antigravityHeaders(token),
68
+ Accept: "application/json",
69
+ };
70
+ }
71
+
72
+ async function postJson(
73
+ path: string,
74
+ token: string,
75
+ body: Record<string, unknown>,
76
+ signal?: AbortSignal,
77
+ ): Promise<{ endpoint: string; status: number; data: unknown }> {
78
+ let lastErrorText = "";
79
+ for (const endpoint of endpointCandidates()) {
80
+ try {
81
+ const res = await antigravityFetch(`${endpoint}${path}`, {
82
+ method: "POST",
83
+ headers: jsonHeaders(token),
84
+ body: JSON.stringify(body),
85
+ signal,
86
+ });
87
+ setLastEndpoint(endpoint);
88
+ setLastStatus(res.status);
89
+ const text = await res.text();
90
+ let data: unknown;
91
+ try {
92
+ data = JSON.parse(text) as unknown;
93
+ } catch {
94
+ data = { raw: text } satisfies ApiErrorBody;
95
+ }
96
+ if (!res.ok) {
97
+ const errorBody = isRecord(data) ? (data as ApiErrorBody) : undefined;
98
+ lastErrorText =
99
+ typeof errorBody?.error?.message === "string" ? errorBody.error.message : text;
100
+ if (![403, 404, 429, 500, 502, 503, 504].includes(res.status)) {
101
+ throw new Error(`${path} failed (${String(res.status)}): ${lastErrorText.slice(0, 300)}`);
102
+ }
103
+ continue;
104
+ }
105
+ return { endpoint, status: res.status, data };
106
+ } catch (error) {
107
+ // An abort must stop this endpoint-candidate loop immediately instead of being
108
+ // treated as "this endpoint failed, try the next one." Check the signal that was
109
+ // passed into *this* call, not the shape of the caught error, so it works
110
+ // uniformly regardless of how a given transport raises the abort.
111
+ if (signal?.aborted) throw error;
112
+ lastErrorText = safeError(error);
113
+ setLastError(lastErrorText);
114
+ }
115
+ }
116
+ throw new Error(`${path} failed: ${lastErrorText || "no endpoint available"}`);
117
+ }
118
+
119
+ function parseQuotaSummary(data: unknown): { groups: QuotaGroup[]; description?: string } {
120
+ const summary = (isRecord(data) ? data : {}) as QuotaSummaryRaw;
121
+ const groups: QuotaGroup[] = [];
122
+ for (const group of summary.groups || []) {
123
+ const buckets: QuotaBucket[] = [];
124
+ for (const bucket of group.buckets || []) {
125
+ const remaining = clampFraction(bucket.remainingFraction);
126
+ if (remaining === undefined && !bucket.bucketId) continue;
127
+ buckets.push({
128
+ bucketId: String(bucket.bucketId || bucket.displayName || "unknown"),
129
+ displayName: String(bucket.displayName || bucket.bucketId || "Limit"),
130
+ window: bucket.window ? String(bucket.window) : undefined,
131
+ resetTime: bucket.resetTime ? String(bucket.resetTime) : undefined,
132
+ description: bucket.description ? String(bucket.description) : undefined,
133
+ remainingFraction: remaining ?? 0,
134
+ });
135
+ }
136
+ if (!buckets.length && !group.displayName) continue;
137
+ groups.push({
138
+ displayName: String(group.displayName || "Quota group"),
139
+ description: group.description ? String(group.description) : undefined,
140
+ buckets,
141
+ });
142
+ }
143
+ return {
144
+ groups,
145
+ description: summary.description ? String(summary.description) : undefined,
146
+ };
147
+ }
148
+
149
+ function parseModels(data: unknown): {
150
+ models: ModelQuotaRow[];
151
+ defaultAgentModelId?: string;
152
+ } {
153
+ const raw = (isRecord(data) ? data : {}) as AvailableModelsRaw;
154
+ const modelsObj = raw.models && isRecord(raw.models) ? raw.models : {};
155
+ const models: ModelQuotaRow[] = [];
156
+ for (const [modelId, info] of Object.entries(modelsObj)) {
157
+ if (!info || !isRecord(info)) continue;
158
+ if (info.isInternal || String(modelId).startsWith("chat_")) continue;
159
+ const qi = isRecord(info.quotaInfo) ? info.quotaInfo : {};
160
+ models.push({
161
+ modelId,
162
+ displayName:
163
+ typeof info.displayName === "string"
164
+ ? info.displayName
165
+ : typeof info.label === "string"
166
+ ? info.label
167
+ : typeof info.modelName === "string"
168
+ ? info.modelName
169
+ : undefined,
170
+ remainingFraction: clampFraction(qi.remainingFraction),
171
+ resetTime: qi.resetTime ? String(qi.resetTime) : undefined,
172
+ modelProvider:
173
+ typeof info.modelProvider === "string"
174
+ ? info.modelProvider
175
+ : typeof info.apiProvider === "string"
176
+ ? info.apiProvider
177
+ : undefined,
178
+ supportsThinking: !!info.supportsThinking,
179
+ supportsImages: !!info.supportsImages,
180
+ recommended: !!info.recommended,
181
+ });
182
+ }
183
+ models.sort((a, b) => a.modelId.localeCompare(b.modelId));
184
+ return {
185
+ models,
186
+ defaultAgentModelId:
187
+ raw.defaultAgentModelId || raw.defaultAgentModel
188
+ ? String(raw.defaultAgentModelId || raw.defaultAgentModel)
189
+ : undefined,
190
+ };
191
+ }
192
+
193
+ function parseTier(value: unknown): TierInfo | undefined {
194
+ if (!isRecord(value)) return undefined;
195
+ const tier = value as TierRaw;
196
+ if (!tier.id && !tier.name) return undefined;
197
+ return {
198
+ id: tier.id ? String(tier.id) : undefined,
199
+ name: tier.name ? String(tier.name) : undefined,
200
+ description: tier.description ? String(tier.description) : undefined,
201
+ };
202
+ }
203
+
204
+ async function loadCodeAssistSafe(token: string, signal?: AbortSignal) {
205
+ try {
206
+ return await postJson(
207
+ "/v1internal:loadCodeAssist",
208
+ token,
209
+ {
210
+ metadata: {
211
+ ideType: "ANTIGRAVITY",
212
+ platform: "PLATFORM_UNSPECIFIED",
213
+ pluginType: "GEMINI",
214
+ },
215
+ },
216
+ signal,
217
+ );
218
+ } catch (error) {
219
+ // An abort must not be folded into "this leg just came back empty": that would let
220
+ // fetchAccountUsage resolve with a partial, silently-truncated result instead of
221
+ // rejecting.
222
+ if (signal?.aborted) throw error;
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * The user-quota-summary RPC is gated behind a paid subscription: free-tier
229
+ * accounts get 403 SUBSCRIPTION_REQUIRED (#3501). It is best-effort diagnostics
230
+ * only — never let it block the rest of the account data (models, tier, project).
231
+ */
232
+ async function fetchQuotaSummarySafe(
233
+ token: string,
234
+ signal?: AbortSignal,
235
+ ): Promise<
236
+ | { ok: true; result: { endpoint: string; status: number; data: unknown } }
237
+ | {
238
+ ok: false;
239
+ error: string;
240
+ }
241
+ > {
242
+ try {
243
+ return {
244
+ ok: true,
245
+ result: await postJson("/v1internal:retrieveUserQuotaSummary", token, {}, signal),
246
+ };
247
+ } catch (error) {
248
+ // An abort must not be folded into "this leg just came back empty": see
249
+ // loadCodeAssistSafe above for the same reasoning.
250
+ if (signal?.aborted) throw error;
251
+ const msg = safeError(error);
252
+ setLastError(msg);
253
+ return { ok: false, error: msg };
254
+ }
255
+ }
256
+
257
+ export async function fetchAccountUsage(
258
+ apiKeyRaw?: string,
259
+ options?: { signal?: AbortSignal },
260
+ ): Promise<AccountUsage> {
261
+ options?.signal?.throwIfAborted();
262
+
263
+ const creds = parseApiKey(apiKeyRaw);
264
+ const initialProjectId =
265
+ creds.projectId ||
266
+ resolveProjectId({
267
+ token: creds.token,
268
+ credentialProjectId: creds.projectId,
269
+ });
270
+
271
+ // Fetch loadCodeAssist, quota summary, and available models all in parallel
272
+ // to minimize command execution latency. Drain every settlement (not just the
273
+ // first rejection): Promise.all's first-reject alone would not guarantee the
274
+ // other two legs' own transport calls have actually stopped, which matters once
275
+ // an abort is in flight.
276
+ const [assistSettled, summarySettled, availableSettled] = await Promise.allSettled([
277
+ loadCodeAssistSafe(creds.token, options?.signal),
278
+ fetchQuotaSummarySafe(creds.token, options?.signal),
279
+ fetchAvailableModelsCatalog(creds.token, initialProjectId, options?.signal),
280
+ ]);
281
+
282
+ // An abort takes priority over a leg that merely came back empty: any genuine
283
+ // rejection here (an abort, or the catalog leg's own "no endpoint available"
284
+ // once every candidate failed) must reject the exported promise rather than be
285
+ // masked by a sibling leg that happened to settle first.
286
+ if (assistSettled.status === "rejected") throw assistSettled.reason as unknown;
287
+ if (summarySettled.status === "rejected") throw summarySettled.reason as unknown;
288
+ if (availableSettled.status === "rejected") throw availableSettled.reason as unknown;
289
+
290
+ const assistResult = assistSettled.value;
291
+ const summaryRes = summarySettled.value;
292
+ const available = availableSettled.value;
293
+
294
+ // Derive project ID from the loadCodeAssist response or stored project ID.
295
+ const discoveredProject = assistResult ? extractProjectId(assistResult.data) : undefined;
296
+ const projectId = resolveProjectId({
297
+ token: creds.token,
298
+ warmedProject: discoveredProject ?? null,
299
+ credentialProjectId: creds.projectId,
300
+ });
301
+ setLastProjectId(projectId);
302
+
303
+ const summary = summaryRes.ok ? summaryRes.result : null;
304
+ const quotaSummaryError = summaryRes.ok ? undefined : summaryRes.error;
305
+ const { groups, description } = summary
306
+ ? parseQuotaSummary(summary.data)
307
+ : { groups: [], description: undefined };
308
+ const { models, defaultAgentModelId } = parseModels(available.data);
309
+
310
+ const assistData = (isRecord(assistResult?.data) ? assistResult.data : {}) as LoadCodeAssistRaw;
311
+ const productTier = parseTier(assistData.currentTier);
312
+ const paidTier = parseTier(assistData.paidTier);
313
+
314
+ // Google returns currentTier=free-tier even for Google AI Pro accounts.
315
+ // The real subscription lives in paidTier (e.g. g1-pro-tier / Google AI Pro).
316
+ const planLabel = paidTier?.name
317
+ ? `${paidTier.name}${paidTier.id ? ` (${paidTier.id})` : ""}`
318
+ : productTier?.name
319
+ ? `${productTier.name}${productTier.id ? ` (${productTier.id})` : ""}`
320
+ : undefined;
321
+
322
+ return {
323
+ projectId,
324
+ endpoint: summary?.endpoint ?? available.endpoint ?? assistResult?.endpoint,
325
+ productTier,
326
+ paidTier,
327
+ planLabel,
328
+ groups,
329
+ groupDescription: description,
330
+ quotaSummaryError,
331
+ models,
332
+ defaultAgentModelId,
333
+ fetchedAt: Date.now(),
334
+ };
335
+ }
336
+
337
+ function quotaErrorNote(msg: string): string {
338
+ if (/SUBSCRIPTION_REQUIRED|#3501|(?:lack|missing).*license/i.test(msg)) {
339
+ return "Aggregate quota summary needs a paid subscription (free-tier can't use that endpoint). Per-model usage is still available via /antigravity.models.";
340
+ }
341
+ return `Aggregate quota summary unavailable: ${msg.slice(0, 160)}`;
342
+ }
343
+
344
+ export function formatUsageSummary(usage: AccountUsage): string {
345
+ const lines: string[] = [];
346
+
347
+ if (usage.planLabel) lines.push(usage.planLabel);
348
+
349
+ if (!usage.groups.length) {
350
+ if (usage.quotaSummaryError) {
351
+ lines.push(quotaErrorNote(usage.quotaSummaryError));
352
+ } else {
353
+ lines.push("No quota groups returned.");
354
+ }
355
+ return lines.join("\n");
356
+ }
357
+
358
+ for (const group of usage.groups) {
359
+ if (lines.length) lines.push("");
360
+ lines.push(group.displayName);
361
+ for (const bucket of group.buckets) {
362
+ const rem = remainingPercent(bucket.remainingFraction);
363
+ lines.push(
364
+ ` ${progressBar(bucket.remainingFraction)} ${bucket.displayName}: ${rem ?? "?"}% left · resets ${formatReset(bucket.resetTime)}`,
365
+ );
366
+ }
367
+ }
368
+
369
+ return lines.join("\n").trimEnd();
370
+ }
371
+
372
+ export function formatModelsList(usage: AccountUsage, opts?: { all?: boolean }): string {
373
+ const lines: string[] = [];
374
+ lines.push("Antigravity available models");
375
+ lines.push(`project=${usage.projectId}`);
376
+ if (usage.defaultAgentModelId) lines.push(`defaultAgentModel=${usage.defaultAgentModelId}`);
377
+ lines.push("");
378
+
379
+ const rows = opts?.all
380
+ ? usage.models
381
+ : usage.models.filter((m) => !/tab_|chat_/i.test(m.modelId));
382
+
383
+ if (!rows.length) {
384
+ lines.push("No models returned.");
385
+ return lines.join("\n");
386
+ }
387
+
388
+ const maxId = Math.max(...rows.map((m) => m.modelId.length), 8);
389
+ for (const m of rows) {
390
+ const rem = remainingPercent(m.remainingFraction);
391
+ const flags = [
392
+ m.recommended ? "recommended" : "",
393
+ m.supportsThinking ? "thinking" : "",
394
+ m.supportsImages ? "images" : "",
395
+ ]
396
+ .filter(Boolean)
397
+ .join(",");
398
+ const name = m.displayName && m.displayName !== m.modelId ? ` ${m.displayName}` : "";
399
+ lines.push(
400
+ `${m.modelId.padEnd(maxId)} rem ${rem === undefined ? " ?" : String(rem).padStart(5)}% reset ${formatReset(m.resetTime).padEnd(8)}${flags ? ` [${flags}]` : ""}${name}`,
401
+ );
402
+ }
403
+ lines.push("");
404
+ lines.push("Note: remaining % is pool-shared (not a private per-model budget).");
405
+ return lines.join("\n");
406
+ }
407
+
408
+ export async function resolveApiKeyFromContext(
409
+ ctx: ExtensionCommandContext,
410
+ ): Promise<string | undefined> {
411
+ try {
412
+ return await ctx.modelRegistry.getApiKeyForProvider("antigravity");
413
+ } catch {
414
+ return undefined;
415
+ }
416
+ }
@@ -0,0 +1,91 @@
1
+ import { antigravityEnv } from "./util.js";
2
+
3
+ /**
4
+ * Node's built-in fetch keeps an idle socket for only 4 seconds unless the server
5
+ * advertises a longer `Keep-Alive: timeout=`. The Cloud Code Assist endpoint sends no
6
+ * such header, and interactive coding turns are almost always more than 4 seconds
7
+ * apart, so without a dedicated pool every message pays a fresh DNS + TCP + TLS
8
+ * handshake. A long-lived dispatcher removes that per-turn setup cost.
9
+ *
10
+ * The dispatcher is scoped to this provider's requests rather than installed with
11
+ * `setGlobalDispatcher`, so it never changes HTTP behaviour for the rest of the host
12
+ * process or for other extensions.
13
+ *
14
+ * Node 22+ global `fetch` cannot take an npm `undici.Agent` (handler mismatch).
15
+ * Those runtimes already pool connections natively, so we skip the custom Agent.
16
+ */
17
+ const KEEP_ALIVE_TIMEOUT_MS = 60_000;
18
+ const KEEP_ALIVE_MAX_TIMEOUT_MS = 5 * 60_000;
19
+ const CONNECT_TIMEOUT_MS = 10_000;
20
+ const PREWARM_TIMEOUT_MS = 5_000;
21
+
22
+ /** `dispatcher` is an undici extension to RequestInit that Node's fetch honours. */
23
+ type DispatcherInit = RequestInit & { dispatcher?: unknown };
24
+
25
+ let dispatcherPromise: Promise<unknown> | undefined;
26
+
27
+ function hasProxyConfiguration(): boolean {
28
+ return ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"].some(
29
+ (name) => Boolean(process.env[name]?.trim()),
30
+ );
31
+ }
32
+
33
+ async function getDispatcher(): Promise<unknown> {
34
+ dispatcherPromise ??= (async () => {
35
+ // Pi configures a proxy-aware global dispatcher. Passing a private Agent here
36
+ // would bypass it and make Antigravity requests connect directly instead.
37
+ if (antigravityEnv("NO_KEEPALIVE") === "1" || hasProxyConfiguration()) {
38
+ return undefined;
39
+ }
40
+ const nodeMajor = Number(process.versions.node?.split(".")[0]);
41
+ if (!Number.isNaN(nodeMajor) && nodeMajor >= 22) {
42
+ return undefined;
43
+ }
44
+ try {
45
+ const { Agent } = await import("undici");
46
+ return new Agent({
47
+ keepAliveTimeout: KEEP_ALIVE_TIMEOUT_MS,
48
+ keepAliveMaxTimeout: KEEP_ALIVE_MAX_TIMEOUT_MS,
49
+ connections: 8,
50
+ connect: { timeout: CONNECT_TIMEOUT_MS },
51
+ // HTTP/2 is opt-in: the endpoint negotiates it, but moving SSE onto h2 is a
52
+ // transport change we do not want to force on every user.
53
+ allowH2: antigravityEnv("HTTP2") === "1",
54
+ });
55
+ } catch {
56
+ // undici unavailable — fall back to Node's default fetch behaviour.
57
+ return undefined;
58
+ }
59
+ })();
60
+ return dispatcherPromise;
61
+ }
62
+
63
+ /** fetch() bound to this provider's keep-alive connection pool when available. */
64
+ export async function antigravityFetch(
65
+ input: string | URL,
66
+ init: RequestInit = {},
67
+ ): Promise<Response> {
68
+ const dispatcher = await getDispatcher();
69
+ if (!dispatcher) return fetch(input, init);
70
+ return fetch(input, { ...init, dispatcher } as DispatcherInit);
71
+ }
72
+
73
+ /**
74
+ * Open the TLS connection when the extension loads so the first message of a session
75
+ * does not pay the handshake either. Best-effort: failures are ignored.
76
+ */
77
+ export function prewarmConnection(url: string): void {
78
+ if (antigravityEnv("NO_PREWARM") === "1") return;
79
+ void (async () => {
80
+ try {
81
+ const res = await antigravityFetch(url, {
82
+ method: "HEAD",
83
+ signal: AbortSignal.timeout(PREWARM_TIMEOUT_MS),
84
+ });
85
+ // Release the socket back to the pool even though HEAD carries no body.
86
+ await res.arrayBuffer();
87
+ } catch {
88
+ // Warm-up only; the real request will establish the connection instead.
89
+ }
90
+ })();
91
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./util.js";
2
+ export * from "./security.js";
3
+ export * from "./http.js";
@@ -0,0 +1,73 @@
1
+ import { antigravityEnv } from "./util.js";
2
+
3
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
4
+ const ALLOWED_API_HOST_SUFFIXES = [".googleapis.com", ".sandbox.googleapis.com"];
5
+
6
+ /** Only loopback binds are allowed so OAuth codes cannot be stolen off-machine. */
7
+ export function resolveCallbackHost(raw = antigravityEnv("CALLBACK_HOST")): string {
8
+ const host = (raw || "127.0.0.1").trim().toLowerCase();
9
+ if (!LOOPBACK_HOSTS.has(host)) {
10
+ throw new Error(
11
+ `Unsafe ANTIGRAVITY_CALLBACK_HOST="${host}". Only loopback hosts are allowed: 127.0.0.1, ::1, localhost.`,
12
+ );
13
+ }
14
+ return host === "localhost" ? "127.0.0.1" : host;
15
+ }
16
+
17
+ /** Prevent token exfiltration via poisoned BASE_URL (SSRF / credential leak). */
18
+ export function assertSafeApiBaseUrl(raw: string): string {
19
+ let url: URL;
20
+ try {
21
+ url = new URL(raw);
22
+ } catch {
23
+ throw new Error(`Invalid ANTIGRAVITY_BASE_URL: ${raw}`);
24
+ }
25
+ if (url.protocol !== "https:") {
26
+ throw new Error(`ANTIGRAVITY_BASE_URL must use https (got ${url.protocol})`);
27
+ }
28
+ if (url.username || url.password) {
29
+ throw new Error("ANTIGRAVITY_BASE_URL must not include credentials");
30
+ }
31
+ const host = url.hostname.toLowerCase();
32
+ const allowed =
33
+ host === "googleapis.com" || ALLOWED_API_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
34
+ if (!allowed) {
35
+ throw new Error(
36
+ `ANTIGRAVITY_BASE_URL host "${host}" is not allowed. Use a *.googleapis.com endpoint.`,
37
+ );
38
+ }
39
+ const path = url.pathname.replace(/\/+$/, "");
40
+ return `${url.origin}${path === "/" ? "" : path}`;
41
+ }
42
+
43
+ /** Redact bearer tokens, refresh tokens, and similar secrets from diagnostics/errors. */
44
+ export function redactSecrets(text: string): string {
45
+ return text
46
+ .replace(/\bya29\.[A-Za-z0-9._~+/-]+=*/g, "[redacted-access-token]")
47
+ .replace(/\b1\/[A-Za-z0-9_-]{20,}/g, "[redacted-refresh-token]")
48
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
49
+ .replace(
50
+ /("?(?:access_token|refresh_token|id_token|token|client_secret|code_verifier|authorization)"?\s*[:=]\s*")[^"]*(")/gi,
51
+ "$1[redacted]$2",
52
+ )
53
+ .replace(
54
+ /("?(?:access_token|refresh_token|id_token|token|client_secret|code_verifier|authorization)"?\s*[:=]\s*)[^\s&,}]+/gi,
55
+ "$1[redacted]",
56
+ );
57
+ }
58
+
59
+ export function maskEmail(email: string | undefined): string | undefined {
60
+ if (!email || typeof email !== "string") return undefined;
61
+ const parts = email.split("@");
62
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return "[redacted-email]";
63
+ const name = parts[0];
64
+ const domain = parts[1];
65
+ const lastChar = name.at(-1) || "";
66
+ const maskedName = name.length > 2 ? `${name[0]}***${lastChar}` : `${name[0]}***`;
67
+ return `${maskedName}@${domain}`;
68
+ }
69
+
70
+ export function safeError(error: unknown): string {
71
+ const raw = error instanceof Error ? error.message : String(error);
72
+ return redactSecrets(raw);
73
+ }