Open History to see the last commit
1import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Clock from "effect/Clock";
4import * as Digest from "effect/crypto/Digest";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12
13import {
14 makeUnavailableUsageLimits,
15 makeUsageLimits,
16} from "@t3tools/provider-core/server/usageLimits";
17
18export function antigravityUserAgent(platform: NodeJS.Platform, architecture: NodeJS.Architecture) {
19 return `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`;
20}
21
22const DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
23const PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com";
24// The ACP runtime meters consumer tiers on the daily host and GCP-terms tiers on prod.
25// The sign-in method stands in for the tier.
26const ENDPOINTS = {
27 "oauth-personal": [DAILY_ENDPOINT, PROD_ENDPOINT],
28 "oauth-business": [PROD_ENDPOINT, DAILY_ENDPOINT],
29};
30const SUMMARY_WINDOW_MINS = new Map([
31 ["5h", 5 * 60],
32 ["weekly", 7 * 24 * 60],
33]);
34const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };
35const TokenFile = Schema.Struct({
36 client_id: Schema.NonEmptyString,
37 client_secret: Schema.NonEmptyString,
38 refresh_token: Schema.NonEmptyString,
39 project_id: Schema.optional(Schema.String),
40});
41const TokenResponse = Schema.Struct({
42 access_token: Schema.NonEmptyString,
43 expires_in: Schema.Number.check(Schema.isGreaterThan(0)),
44});
45const UserInfo = Schema.Struct({ id: Schema.NonEmptyString });
46const Quota = Schema.Struct({
47 remainingFraction: Schema.optional(Schema.Number),
48 resetTime: Schema.optional(Schema.String),
49});
50const Bucket = Schema.Struct({
51 ...Quota.fields,
52 bucketId: Schema.NonEmptyString,
53 window: Schema.optional(Schema.String),
54 displayName: Schema.optional(Schema.String),
55 description: Schema.optional(Schema.String),
56 disabled: Schema.optional(Schema.Boolean),
57});
58const Summary = Schema.Struct({
59 groups: Schema.optional(
60 Schema.Array(
61 Schema.Struct({
62 displayName: Schema.optional(Schema.String),
63 buckets: Schema.optional(Schema.Array(Bucket)),
64 }),
65 ),
66 ),
67 buckets: Schema.optional(Schema.Array(Bucket)),
68});
69const Models = Schema.Struct({
70 models: Schema.Record(
71 Schema.String,
72 Schema.Struct({
73 modelProvider: Schema.optional(Schema.String),
74 quotaInfo: Schema.optional(Quota),
75 weeklyQuotaInfo: Schema.optional(Quota),
76 }),
77 ),
78});
79
80const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile));
81const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
82const decodeUserInfo = Schema.decodeUnknownEffect(UserInfo);
83const decodeSummary = Schema.decodeUnknownEffect(Summary);
84const decodeModels = Schema.decodeUnknownEffect(Models);
85
86function iso(value: string | undefined): string | undefined {
87 const parsed = value ? DateTime.make(value) : Option.none();
88 return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : undefined;
89}
90
91function quotaWindow(
92 id: string,
93 label: string,
94 quota: typeof Quota.Type,
95 kind: ServerProviderUsageWindow["kind"] = "other",
96 windowDurationMins?: number,
97): ServerProviderUsageWindow | undefined {
98 if (quota.remainingFraction === undefined || !Number.isFinite(quota.remainingFraction)) {
99 return undefined;
100 }
101 const resetsAt = iso(quota.resetTime);
102 return {
103 id,
104 label,
105 kind,
106 usedPercent: (1 - Math.max(0, Math.min(1, quota.remainingFraction))) * 100,
107 ...(resetsAt ? { resetsAt } : {}),
108 ...(windowDurationMins === undefined ? {} : { windowDurationMins }),
109 };
110}
111
112function summaryResetTime(
113 bucket: typeof Bucket.Type,
114 windowDurationMins: number | undefined,
115 requestedAt: number,
116): string | undefined {
117 // Untouched summary buckets report a rolling full-window reset.
118 const untouched =
119 bucket.remainingFraction === 1 &&
120 bucket.description === undefined &&
121 windowDurationMins !== undefined &&
122 Math.abs(Date.parse(bucket.resetTime ?? "") - requestedAt - windowDurationMins * 60_000) <=
123 60_000;
124 return untouched ? undefined : bucket.resetTime;
125}
126
127const sha256 = Effect.fnUntraced(function* (text: string) {
128 const digest = yield* (yield* Digest.Digest).digest(
129 Digest.sha256,
130 new TextEncoder().encode(text),
131 );
132 return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
133});
134
135/** Keys an account by its Google ID, which matches across machines and hubs; refresh tokens do not. */
136export const antigravityAccountFingerprint = (userInfo: unknown) =>
137 decodeUserInfo(userInfo).pipe(Effect.flatMap((info) => sha256(`google-account\0${info.id}`)));
138
139/** Windows from a `retrieveUserQuotaSummary` answer requested at `requestedAt`. */
140export const antigravitySummaryWindows = (response: unknown, requestedAt: number) =>
141 decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt)));
142
143function summaryWindows(summary: typeof Summary.Type, requestedAt: number) {
144 const windows = new Map<string, ServerProviderUsageWindow>();
145 const groups = summary.groups?.some((group) => group.buckets?.length)
146 ? summary.groups
147 : [{ buckets: summary.buckets }];
148 for (const group of groups ?? []) {
149 for (const bucket of group.buckets ?? []) {
150 if (bucket.disabled) continue;
151 // Argon and Carbon are metered separately from the default Gemini buckets.
152 const family = bucket.bucketId.startsWith("gemini-argon-")
153 ? group.displayName?.trim() || "Gemini Argon"
154 : bucket.bucketId.startsWith("gemini-carbon-")
155 ? group.displayName?.trim() || "Gemini Carbon"
156 : bucket.bucketId.startsWith("gemini-")
157 ? "Gemini"
158 : bucket.bucketId.startsWith("3p-")
159 ? "Claude & GPT (shared)"
160 : group.displayName?.trim() || bucket.bucketId;
161 const weekly = bucket.window === "weekly";
162 const session = bucket.window === "5h";
163 const windowDurationMins = SUMMARY_WINDOW_MINS.get(bucket.window ?? "");
164 const window = quotaWindow(
165 bucket.bucketId,
166 `${family} · ${weekly ? "Weekly" : session ? "5-hour" : bucket.displayName?.trim() || "Quota"}`,
167 { ...bucket, resetTime: summaryResetTime(bucket, windowDurationMins, requestedAt) },
168 weekly ? "weekly" : session ? "session" : "other",
169 windowDurationMins,
170 );
171 if (window) windows.set(window.id, window);
172 }
173 }
174 return [...windows.values()];
175}
176
177/** Older accounts expose model quotas without a window length. Do not invent a reset cadence. */
178function modelWindows(models: typeof Models.Type) {
179 const windows = new Map<string, ServerProviderUsageWindow>();
180 for (const [modelId, model] of Object.entries(models.models)) {
181 const family =
182 model.modelProvider === "MODEL_PROVIDER_GOOGLE" || modelId.startsWith("gemini-")
183 ? "Gemini"
184 : model.modelProvider === "MODEL_PROVIDER_ANTHROPIC" || modelId.startsWith("claude-")
185 ? "Claude"
186 : model.modelProvider === "MODEL_PROVIDER_OPENAI" || modelId.startsWith("gpt-")
187 ? "GPT"
188 : undefined;
189 if (!family) continue;
190 for (const [suffix, quota] of [
191 ["quota", model.quotaInfo],
192 ["weekly", model.weeklyQuotaInfo],
193 ] as const) {
194 if (!quota) continue;
195 const window = quotaWindow(
196 `${family.toLowerCase()}-${suffix}`,
197 `${family} · ${suffix === "weekly" ? "Weekly" : "Reported quota"}`,
198 quota,
199 suffix === "weekly" ? "weekly" : "other",
200 suffix === "weekly" ? 7 * 24 * 60 : undefined,
201 );
202 if (!window) continue;
203 const previous = windows.get(window.id);
204 if (!previous || window.usedPercent > previous.usedPercent) windows.set(window.id, window);
205 }
206 }
207 return [...windows.values()];
208}
209
210export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(function* (input: {
211 readonly profileDirectory: string;
212 readonly authMethod: AntigravitySettings["authMethod"];
213}) {
214 const fs = yield* FileSystem.FileSystem;
215 const path = yield* Path.Path;
216 const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
217 const crypto = yield* Digest.Digest;
218 const platform = yield* HostProcess.Platform;
219 const architecture = yield* HostProcess.Architecture;
220 const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json");
221 let cached:
222 | { fingerprint: string; account: string; accessToken: string; expiresAt: number }
223 | undefined;
224 let generation = 0;
225
226 const request = (request: HttpClientRequest.HttpClientRequest) =>
227 client.execute(request).pipe(
228 Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),
229 Effect.flatMap(HttpClientResponse.filterStatusOk),
230 Effect.flatMap((response) => response.json),
231 Effect.timeout("10 seconds"),
232 );
233 const read = Effect.gen(function* () {
234 const startedGeneration = generation;
235 const checkedAt = DateTime.formatIso(yield* DateTime.now);
236 if (input.authMethod !== "oauth-personal" && input.authMethod !== "oauth-business") {
237 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
238 }
239 if (!(yield* fs.exists(tokenPath))) {
240 cached = undefined;
241 return makeUnavailableUsageLimits({
242 checkedAt,
243 reason: "unsupported",
244 message: "Sign in with Google to see Antigravity limits.",
245 });
246 }
247 const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));
248 const fingerprint = yield* sha256(
249 `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
250 );
251 const now = yield* Clock.currentTimeMillis;
252 let accessToken: string;
253 let account: string;
254 if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
255 cached = undefined;
256 const refreshed = yield* request(
257 HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe(
258 HttpClientRequest.bodyUrlParams({
259 client_id: token.client_id,
260 client_secret: token.client_secret,
261 refresh_token: token.refresh_token,
262 grant_type: "refresh_token",
263 }),
264 ),
265 ).pipe(Effect.flatMap(decodeTokenResponse));
266 accessToken = refreshed.access_token;
267 // Use Google account IDs to merge accounts across environments; refresh tokens differ per machine.
268 account = yield* request(
269 HttpClientRequest.get("https://www.googleapis.com/oauth2/v2/userinfo").pipe(
270 HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }),
271 ),
272 ).pipe(
273 Effect.flatMap(antigravityAccountFingerprint),
274 Effect.orElseSucceed(() => fingerprint),
275 );
276 if (generation === startedGeneration) {
277 cached = {
278 fingerprint,
279 account,
280 accessToken,
281 expiresAt: now + refreshed.expires_in * 1000,
282 };
283 }
284 } else {
285 accessToken = cached.accessToken;
286 account = cached.account;
287 }
288 const endpoints = ENDPOINTS[input.authMethod];
289 // Merge endpoints because quota groups differ; the metered host's reading of a bucket wins.
290 const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(
291 operation: string,
292 parse: (
293 response: unknown,
294 requestedAt: number,
295 ) => Effect.Effect<ServerProviderUsageWindow[], E>,
296 ) {
297 const responses = yield* Effect.forEach(
298 endpoints,
299 (endpoint) =>
300 Effect.gen(function* () {
301 // Capture each request after auth, so refresh latency does not widen the tolerance.
302 const requestedAt = yield* Clock.currentTimeMillis;
303 return yield* request(
304 HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe(
305 HttpClientRequest.setHeaders({
306 Authorization: `Bearer ${accessToken}`,
307 "User-Agent": antigravityUserAgent(platform, architecture),
308 }),
309 HttpClientRequest.bodyJsonUnsafe(
310 token.project_id ? { project: token.project_id } : {},
311 ),
312 ),
313 ).pipe(
314 Effect.flatMap((response) => parse(response, requestedAt)),
315 Effect.orElseSucceed(() => []),
316 );
317 }),
318 { concurrency: "unbounded" },
319 );
320 const windows = new Map<string, ServerProviderUsageWindow>();
321 for (const window of responses.flat()) {
322 if (!windows.has(window.id)) windows.set(window.id, window);
323 }
324 return [...windows.values()];
325 });
326 const summary = yield* fetchWindows("retrieveUserQuotaSummary", antigravitySummaryWindows);
327 const windows = summary.length
328 ? summary
329 : yield* fetchWindows("fetchAvailableModels", (response) =>
330 decodeModels(response).pipe(Effect.map(modelWindows)),
331 );
332 if (windows.length === 0) {
333 return makeUnavailableUsageLimits({
334 checkedAt,
335 reason: "probeFailed",
336 message: "Antigravity did not return usable quota information.",
337 });
338 }
339 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account };
340 });
341 const probe = read.pipe(
342 Effect.provideService(Digest.Digest, crypto),
343 Effect.timeout("30 seconds"),
344 Effect.catch(() =>
345 Effect.gen(function* () {
346 return makeUnavailableUsageLimits({
347 checkedAt: DateTime.formatIso(yield* DateTime.now),
348 reason: "probeFailed",
349 message: "Could not read Antigravity limits. Refresh provider status to retry.",
350 });
351 }),
352 ),
353 );
354 return {
355 probe,
356 clear: Effect.sync(() => {
357 cached = undefined;
358 generation++;
359 }),
360 };
361});