1import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Clock from "effect/Clock";
4import * as Crypto from "effect/Crypto";
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
18const ENDPOINTS = [
19 "https://daily-cloudcode-pa.googleapis.com",
20 "https://cloudcode-pa.googleapis.com",
21];
22const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };
23const TokenFile = Schema.Struct({
24 client_id: Schema.NonEmptyString,
25 client_secret: Schema.NonEmptyString,
26 refresh_token: Schema.NonEmptyString,
27 project_id: Schema.optional(Schema.String),
28});
29const TokenResponse = Schema.Struct({
30 access_token: Schema.NonEmptyString,
31 expires_in: Schema.Number.check(Schema.isGreaterThan(0)),
32});
33const UserInfo = Schema.Struct({ id: Schema.NonEmptyString });
34const Quota = Schema.Struct({
35 remainingFraction: Schema.optional(Schema.Number),
36 resetTime: Schema.optional(Schema.String),
37});
38const Bucket = Schema.Struct({
39 ...Quota.fields,
40 bucketId: Schema.NonEmptyString,
41 window: Schema.optional(Schema.String),
42 displayName: Schema.optional(Schema.String),
43 disabled: Schema.optional(Schema.Boolean),
44});
45const Summary = Schema.Struct({
46 groups: Schema.optional(
47 Schema.Array(
48 Schema.Struct({
49 displayName: Schema.optional(Schema.String),
50 buckets: Schema.optional(Schema.Array(Bucket)),
51 }),
52 ),
53 ),
54 buckets: Schema.optional(Schema.Array(Bucket)),
55});
56const Models = Schema.Struct({
57 models: Schema.Record(
58 Schema.String,
59 Schema.Struct({
60 modelProvider: Schema.optional(Schema.String),
61 quotaInfo: Schema.optional(Quota),
62 weeklyQuotaInfo: Schema.optional(Quota),
63 }),
64 ),
65});
66
67const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile));
68const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
69const decodeUserInfo = Schema.decodeUnknownEffect(UserInfo);
70const decodeSummary = Schema.decodeUnknownEffect(Summary);
71const decodeModels = Schema.decodeUnknownEffect(Models);
72
73function iso(value: string | undefined): string | undefined {
74 const parsed = value ? DateTime.make(value) : Option.none();
75 return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : undefined;
76}
77
78function quotaWindow(
79 id: string,
80 label: string,
81 quota: typeof Quota.Type,
82 kind: ServerProviderUsageWindow["kind"] = "other",
83 windowDurationMins?: number,
84): ServerProviderUsageWindow | undefined {
85 if (quota.remainingFraction === undefined || !Number.isFinite(quota.remainingFraction)) {
86 return undefined;
87 }
88 const resetsAt = iso(quota.resetTime);
89 return {
90 id,
91 label,
92 kind,
93 usedPercent: (1 - Math.max(0, Math.min(1, quota.remainingFraction))) * 100,
94 ...(resetsAt ? { resetsAt } : {}),
95 ...(windowDurationMins === undefined ? {} : { windowDurationMins }),
96 };
97}
98
99/** The summary names the actual shared buckets; Claude and GPT consume one allowance. */
100function summaryWindows(summary: typeof Summary.Type) {
101 const windows = new Map<string, ServerProviderUsageWindow>();
102 const groups = summary.groups?.some((group) => group.buckets?.length)
103 ? summary.groups
104 : [{ buckets: summary.buckets }];
105 for (const group of groups ?? []) {
106 for (const bucket of group.buckets ?? []) {
107 if (bucket.disabled) continue;
108 const family = bucket.bucketId.startsWith("gemini-")
109 ? "Gemini"
110 : bucket.bucketId.startsWith("3p-")
111 ? "Claude & GPT (shared)"
112 : group.displayName?.trim() || bucket.bucketId;
113 const weekly = bucket.window === "weekly";
114 const session = bucket.window === "5h";
115 const window = quotaWindow(
116 bucket.bucketId,
117 `${family} · ${weekly ? "Weekly" : session ? "5-hour" : bucket.displayName?.trim() || "Quota"}`,
118 bucket,
119 weekly ? "weekly" : session ? "session" : "other",
120 weekly ? 7 * 24 * 60 : session ? 5 * 60 : undefined,
121 );
122 if (window) windows.set(window.id, window);
123 }
124 }
125 return [...windows.values()];
126}
127
128/** Older accounts expose model quotas without a window length. Do not invent a reset cadence. */
129function modelWindows(models: typeof Models.Type) {
130 const windows = new Map<string, ServerProviderUsageWindow>();
131 for (const [modelId, model] of Object.entries(models.models)) {
132 const family =
133 model.modelProvider === "MODEL_PROVIDER_GOOGLE" || modelId.startsWith("gemini-")
134 ? "Gemini"
135 : model.modelProvider === "MODEL_PROVIDER_ANTHROPIC" || modelId.startsWith("claude-")
136 ? "Claude"
137 : model.modelProvider === "MODEL_PROVIDER_OPENAI" || modelId.startsWith("gpt-")
138 ? "GPT"
139 : undefined;
140 if (!family) continue;
141 for (const [suffix, quota] of [
142 ["quota", model.quotaInfo],
143 ["weekly", model.weeklyQuotaInfo],
144 ] as const) {
145 if (!quota) continue;
146 const window = quotaWindow(
147 `${family.toLowerCase()}-${suffix}`,
148 `${family} · ${suffix === "weekly" ? "Weekly" : "Reported quota"}`,
149 quota,
150 suffix === "weekly" ? "weekly" : "other",
151 suffix === "weekly" ? 7 * 24 * 60 : undefined,
152 );
153 if (!window) continue;
154 const previous = windows.get(window.id);
155 if (!previous || window.usedPercent > previous.usedPercent) windows.set(window.id, window);
156 }
157 }
158 return [...windows.values()];
159}
160
161/** Reads this instance's Google profile, with a memory-only access token and no credential writes. */
162export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(function* (input: {
163 readonly profileDirectory: string;
164 readonly authMethod: AntigravitySettings["authMethod"];
165}) {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
169 const crypto = yield* Crypto.Crypto;
170 const platform = yield* HostProcess.Platform;
171 const architecture = yield* HostProcess.Architecture;
172 const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json");
173 let cached:
174 | { fingerprint: string; account: string; accessToken: string; expiresAt: number }
175 | undefined;
176 let generation = 0;
177
178 const sha256 = (text: string) =>
179 crypto
180 .digest("SHA-256", new TextEncoder().encode(text))
181 .pipe(
182 Effect.map((digest) =>
183 Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""),
184 ),
185 );
186
187 const request = (request: HttpClientRequest.HttpClientRequest) =>
188 client.execute(request).pipe(
189 Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),
190 Effect.flatMap(HttpClientResponse.filterStatusOk),
191 Effect.flatMap((response) => response.json),
192 Effect.timeout("10 seconds"),
193 );
194 const read = Effect.gen(function* () {
195 const startedGeneration = generation;
196 const checkedAt = DateTime.formatIso(yield* DateTime.now);
197 if (input.authMethod !== "oauth-personal" && input.authMethod !== "oauth-business") {
198 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
199 }
200 if (!(yield* fs.exists(tokenPath))) {
201 cached = undefined;
202 return makeUnavailableUsageLimits({
203 checkedAt,
204 reason: "unsupported",
205 message: "Sign in with Google to see Antigravity limits.",
206 });
207 }
208 const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));
209 const fingerprint = yield* sha256(
210 `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
211 );
212 const now = yield* Clock.currentTimeMillis;
213 let accessToken: string;
214 let account: string;
215 if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
216 cached = undefined;
217 const refreshed = yield* request(
218 HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe(
219 HttpClientRequest.bodyUrlParams({
220 client_id: token.client_id,
221 client_secret: token.client_secret,
222 refresh_token: token.refresh_token,
223 grant_type: "refresh_token",
224 }),
225 ),
226 ).pipe(Effect.flatMap(decodeTokenResponse));
227 accessToken = refreshed.access_token;
228 // Each machine's sign-in has its own refresh token, so the Google
229 // account id is what lets clients merge one account across environments.
230 account = yield* request(
231 HttpClientRequest.get("https://www.googleapis.com/oauth2/v2/userinfo").pipe(
232 HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }),
233 ),
234 ).pipe(
235 Effect.flatMap(decodeUserInfo),
236 Effect.flatMap((info) => sha256(`google-account\0${info.id}`)),
237 Effect.orElseSucceed(() => fingerprint),
238 );
239 if (generation === startedGeneration) {
240 cached = {
241 fingerprint,
242 account,
243 accessToken,
244 expiresAt: now + refreshed.expires_in * 1000,
245 };
246 }
247 } else {
248 accessToken = cached.accessToken;
249 account = cached.account;
250 }
251 const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(
252 operation: string,
253 parse: (response: unknown) => Effect.Effect<ServerProviderUsageWindow[], E>,
254 ) {
255 for (const endpoint of ENDPOINTS) {
256 const windows = yield* request(
257 HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe(
258 HttpClientRequest.setHeaders({
259 Authorization: `Bearer ${accessToken}`,
260 "User-Agent": `antigravity/hub/1.0.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`,
261 }),
262 HttpClientRequest.bodyJsonUnsafe(token.project_id ? { project: token.project_id } : {}),
263 ),
264 ).pipe(
265 Effect.flatMap(parse),
266 Effect.orElseSucceed(() => []),
267 );
268 if (windows.length > 0) return windows;
269 }
270 return [];
271 });
272 const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response) =>
273 decodeSummary(response).pipe(Effect.map(summaryWindows)),
274 );
275 const windows = summary.length
276 ? summary
277 : yield* fetchWindows("fetchAvailableModels", (response) =>
278 decodeModels(response).pipe(Effect.map(modelWindows)),
279 );
280 if (windows.length === 0) {
281 return makeUnavailableUsageLimits({
282 checkedAt,
283 reason: "probeFailed",
284 message: "Antigravity did not return usable quota information.",
285 });
286 }
287 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account };
288 });
289 const probe = read.pipe(
290 Effect.timeout("30 seconds"),
291 Effect.catch(() =>
292 Effect.gen(function* () {
293 return makeUnavailableUsageLimits({
294 checkedAt: DateTime.formatIso(yield* DateTime.now),
295 reason: "probeFailed",
296 message: "Could not read Antigravity limits. Refresh provider status to retry.",
297 });
298 }),
299 ),
300 );
301 return {
302 probe,
303 clear: Effect.sync(() => {
304 cached = undefined;
305 generation++;
306 }),
307 };
308});