| 1 | import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Clock from "effect/Clock"; |
| 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 12 | |
| 13 | import { |
| 14 | makeUnavailableUsageLimits, |
| 15 | makeUsageLimits, |
| 16 | } from "@t3tools/provider-core/server/usageLimits"; |
| 17 | |
| 18 | const ENDPOINTS = [ |
| 19 | "https://daily-cloudcode-pa.googleapis.com", |
| 20 | "https://cloudcode-pa.googleapis.com", |
| 21 | ]; |
| 22 | const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; |
| 23 | const 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 | }); |
| 29 | const TokenResponse = Schema.Struct({ |
| 30 | access_token: Schema.NonEmptyString, |
| 31 | expires_in: Schema.Number.check(Schema.isGreaterThan(0)), |
| 32 | }); |
| 33 | const UserInfo = Schema.Struct({ id: Schema.NonEmptyString }); |
| 34 | const Quota = Schema.Struct({ |
| 35 | remainingFraction: Schema.optional(Schema.Number), |
| 36 | resetTime: Schema.optional(Schema.String), |
| 37 | }); |
| 38 | const 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 | }); |
| 45 | const 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 | }); |
| 56 | const 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 | |
| 67 | const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile)); |
| 68 | const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); |
| 69 | const decodeUserInfo = Schema.decodeUnknownEffect(UserInfo); |
| 70 | const decodeSummary = Schema.decodeUnknownEffect(Summary); |
| 71 | const decodeModels = Schema.decodeUnknownEffect(Models); |
| 72 | |
| 73 | function 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 | |
| 78 | function 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. */ |
| 100 | function 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. */ |
| 129 | function 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. */ |
| 162 | export 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 | }); |