From 2855ea2bf45a4fe00c5793453fd1c304ad6b4db8 Mon Sep 17 00:00:00 2001 From: Szymon Sypniewicz <22638839+Newarr@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:20:46 +0100 Subject: [PATCH 1/3] feat(usage): Antigravity reports weekly limits for each model group --- .../Drivers/AntigravityDriver.test.ts | 7 + .../src/provider/Drivers/AntigravityDriver.ts | 12 ++ .../provider/Layers/AntigravityProvider.ts | 58 +++++--- .../provider/Layers/antigravityUsageLimits.ts | 125 ++++++++++++++++++ .../src/provider/antigravityAuthSupport.ts | 6 +- docs/internals/providers.md | 10 +- docs/user/usage.md | 4 + 7 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/provider/Layers/antigravityUsageLimits.ts diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 1aad2d3310cc..5144573535f9 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import { HttpClient } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -262,6 +263,12 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("A profile without a Google sign-in must not call Google")), + ), + ), ); it.layer(testLayer)("AntigravityDriver", (it) => { diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index cabcc643006e..f5f720e40a1b 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import type { AcpError } from "effect-acp/errors"; @@ -31,6 +32,7 @@ import { antigravityAuthConfigIssue, antigravityAuthLabel, antigravityAuthUsesBrowser, + antigravityTokenPath, buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, prepareAntigravityProfile, @@ -53,6 +55,7 @@ import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/Antigr import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts"; +import { readAntigravityUsageLimits } from "../Layers/antigravityUsageLimits.ts"; import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { @@ -74,6 +77,7 @@ export type AntigravityDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem + | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 | ModelManifest.ModelManifest | Path.Path @@ -91,6 +95,7 @@ export const AntigravityDriver: ProviderDriver false), ), + readUsageLimits: readAntigravityUsageLimits({ + authMethod: auth.authMethod, + tokenPath: antigravityTokenPath(path, profileDirectory), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 9f850c6f0e04..eb1f070f22bc 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -6,6 +6,7 @@ import { type ServerProvider, type ServerProviderModel, type ServerProviderSlashCommand, + type ServerProviderUsageLimits, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; @@ -29,6 +30,7 @@ import { isCommandMissingCause, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { resolveUsageLimitsAfterProbe } from "../providerUsageLimits.ts"; const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; @@ -123,6 +125,7 @@ interface AntigravityProviderOptions { EffectAcpErrors.AcpError | ProviderSetupError >; readonly supportsTextGeneration: Effect.Effect; + readonly readUsageLimits?: Effect.Effect; readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; /** Auth type and label published once a session authenticates. */ readonly auth?: { readonly type: string; readonly label: string }; @@ -192,10 +195,23 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; + const probedUsageLimits = + initialized !== undefined && + before.draft.auth.status !== "unauthenticated" && + options.readUsageLimits + ? yield* options.readUsageLimits + : undefined; const updatedAt = DateTime.formatIso(yield* DateTime.now); const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { if (state.authRevision !== before.authRevision) return state; - const { message: _previousMessage, ...draft } = state.draft; + const { message: _previousMessage, usageLimits: previousUsageLimits, ...draft } = state.draft; + const usageLimits = + initialized === undefined && !missingInstallation + ? previousUsageLimits + : resolveUsageLimitsAfterProbe({ + published: previousUsageLimits, + probed: probedUsageLimits, + }); const authenticated = draft.auth.status === "authenticated"; const message = errorMessage ?? @@ -227,6 +243,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func supportsTextGeneration && draft.auth.status !== "unauthenticated", } : {}), + ...(usageLimits ? { usageLimits } : {}), ...(message ? { message } : {}), }, } satisfies AntigravityProviderState; @@ -349,28 +366,29 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); }); + const scope = yield* Effect.scope; const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); - yield* SubscriptionRef.update( - metadata, - (state) => - ({ - authRevision: state.authRevision + 1, - draft: { - ...state.draft, - auth: { status: "unauthenticated" }, - status: settings.enabled ? "warning" : "disabled", - message: SIGN_IN_MESSAGE, - checkedAt: updatedAt, - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: [], - supportsTextGeneration: false, - }, - }) satisfies AntigravityProviderState, - ); + yield* SubscriptionRef.update(metadata, (state) => { + const { usageLimits: _usageLimits, ...draft } = state.draft; + return { + authRevision: state.authRevision + 1, + draft: { + ...draft, + auth: { status: "unauthenticated" }, + status: settings.enabled ? "warning" : "disabled", + message: SIGN_IN_MESSAGE, + checkedAt: updatedAt, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + }, + } satisfies AntigravityProviderState; + }); discoveredSkills.clear(); + yield* managed.refresh.pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); }); const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( diff --git a/apps/server/src/provider/Layers/antigravityUsageLimits.ts b/apps/server/src/provider/Layers/antigravityUsageLimits.ts new file mode 100644 index 000000000000..ad836ad09fb0 --- /dev/null +++ b/apps/server/src/provider/Layers/antigravityUsageLimits.ts @@ -0,0 +1,125 @@ +import type { AntigravityAuthMethod, ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "../providerUsageLimits.ts"; + +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const QUOTA_SUMMARY_URL = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; +const WEEK_MINS = 7 * 24 * 60; + +const AcpToken = Schema.Struct({ + client_id: Schema.String, + client_secret: Schema.String, + refresh_token: Schema.String, +}); +const decodeAcpToken = Schema.decodeEffect(Schema.fromJsonString(AcpToken)); +const AccessToken = Schema.Struct({ access_token: Schema.String }); + +const QuotaSummary = Schema.Struct({ + groups: Schema.optional( + Schema.Array( + Schema.Struct({ + displayName: Schema.optional(Schema.String), + buckets: Schema.optional( + Schema.Array( + Schema.Struct({ + bucketId: Schema.optional(Schema.String), + window: Schema.optional(Schema.String), + resetTime: Schema.optional(Schema.String), + remainingFraction: Schema.optional(Schema.Number), + }), + ), + ), + }), + ), + ), +}); + +function antigravityQuotaSummaryToLimits(summary: typeof QuotaSummary.Type, checkedAt: string) { + const windows = (summary.groups ?? []).flatMap((group) => { + const scope = group.displayName?.replace(/\s+models$/i, "").trim(); + return (group.buckets ?? []).flatMap((bucket): ServerProviderUsageWindow[] => { + const id = bucket.bucketId?.trim(); + const remaining = bucket.remainingFraction; + if (!id || remaining === undefined || !Number.isFinite(remaining)) return []; + const kind = + bucket.window === "weekly" ? "weekly" : bucket.window === "monthly" ? "monthly" : "other"; + const period = kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : undefined; + const reset = bucket.resetTime ? DateTime.make(bucket.resetTime) : Option.none(); + return [ + { + id, + kind, + label: [period, scope].filter(Boolean).join(" ยท ") || id, + usedPercent: clampPercent((1 - remaining) * 100), + ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), + ...(kind === "weekly" ? { windowDurationMins: WEEK_MINS } : {}), + }, + ]; + }); + }); + return windows.length > 0 + ? makeUsageLimits({ checkedAt, windows }) + : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); +} + +export const readAntigravityUsageLimits = Effect.fn("readAntigravityUsageLimits")( + function* (input: { readonly authMethod: AntigravityAuthMethod; readonly tokenPath: string }) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (input.authMethod !== "oauth-personal") { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(input.tokenPath))) return undefined; + const credential = yield* decodeAcpToken(yield* fs.readFileString(input.tokenPath)); + const client = yield* HttpClient.HttpClient; + const token = yield* client + .execute( + HttpClientRequest.post(GOOGLE_TOKEN_URL).pipe( + HttpClientRequest.bodyUrlParams({ + client_id: credential.client_id, + client_secret: credential.client_secret, + refresh_token: credential.refresh_token, + grant_type: "refresh_token", + }), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(AccessToken)), + ); + const summary = yield* client + .execute( + HttpClientRequest.post(QUOTA_SUMMARY_URL).pipe( + HttpClientRequest.bearerToken(token.access_token), + HttpClientRequest.setHeader("user-agent", "antigravity"), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(QuotaSummary)), + ); + return antigravityQuotaSummaryToLimits(summary, checkedAt); + }).pipe( + Effect.timeout("15 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Antigravity could not read usage limits.", + }), + ), + ); + }, +); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 6eaa4ec7a497..7df073c3b720 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -110,6 +110,10 @@ export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { }; /** True for the two methods that open a Google sign-in page. */ +export function antigravityTokenPath(path: Path.Path, profileDirectory: string): string { + return path.join(path.resolve(profileDirectory), "antigravity-acp", "acp_token.json"); +} + export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean { return authMethod === "oauth-personal" || authMethod === "oauth-business"; } @@ -354,7 +358,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( platform, geminiHome, acpDirectory, - tokenPath: path.join(acpDirectory, "acp_token.json"), + tokenPath: antigravityTokenPath(path, geminiHome), tempDirectory, browserCommand, }; diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a68980e54845..22927a6acc00 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -54,13 +54,19 @@ and removal must respect those leases instead of replacing executables under a r Opening a provider session can start MCP servers, run hooks, or launch a login browser. [Grok probes](../../apps/server/src/provider/Layers/GrokProvider.ts) avoid authentication and session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for -explicit setup or model refresh; background checks use initialization only. +explicit setup or model refresh. Background checks resolve the install on disk and do not start +the agent. + +They read [usage limits](../../apps/server/src/provider/Layers/antigravityUsageLimits.ts) over HTTP. +The stored refresh token becomes an access token that stays in memory, and the token file is never +written. Google does not rotate the refresh token on that grant, so the read cannot race the agent's +own refresh. [Antigravity sign-in](../../apps/server/src/provider/AntigravityAuth.ts) belongs to the initiating T3 auth session. The client carries the return URL back to the environment because the provider's loopback listener may be on another machine. Forward only the callback for the owned pending flow; a successful callback HTTP request is not proof that provider authentication finished. The native -process owns token exchange and storage. +process owns the sign-in code exchange and token storage. Managed ChatGPT sign-in for a remote environment can finish on a local primary. The [primary handoff](../../apps/server/src/provider/CodexChatGptHandoff.ts) uses an ephemeral diff --git a/docs/user/usage.md b/docs/user/usage.md index 24db5f3f3b92..3ba55fe26fb3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -106,6 +106,10 @@ Grok reports the remaining subscription allowance and reset time for its current after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication or endpoint configurations do not report subscription limits. +Antigravity reports the allowance and reset time of each model group after you sign in with the +Google account method. Gemini Enterprise, Gemini API key, and Agent Platform connections do not +report subscription limits. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. From a96cc9581b08d28c65e9d6e84e9183a6292eb5c3 Mon Sep 17 00:00:00 2001 From: Szymon Sypniewicz <22638839+Newarr@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:40:55 +0100 Subject: [PATCH 2/3] fix(usage): signing out clears Antigravity limits in the same update --- apps/server/src/provider/Layers/AntigravityProvider.ts | 2 -- apps/server/src/provider/makeManagedServerProvider.ts | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index eb1f070f22bc..3db29b9cc4d7 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -366,7 +366,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); }); - const scope = yield* Effect.scope; const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update(metadata, (state) => { @@ -388,7 +387,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func } satisfies AntigravityProviderState; }); discoveredSkills.clear(); - yield* managed.refresh.pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); }); const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 7884b9d1e158..bf21d8eaad46 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -86,7 +86,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( } // Enrichment derives from the snapshot it was handed; a runtime usage // update that landed since must not be reverted by it. - const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits); + const merged = + nextSnapshot.auth.status === "unauthenticated" + ? nextSnapshot + : withUsageLimits(nextSnapshot, state.snapshot.usageLimits); if (Equal.equals(state.snapshot, merged)) { return [null, state] as const; } From b2e91aacdce3d96f99884d255ae81146cc074d5f Mon Sep 17 00:00:00 2001 From: Szymon Sypniewicz <22638839+Newarr@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:42:43 +0100 Subject: [PATCH 3/3] fix(usage): pooled limits count one Google account once and label every window --- .../provider/Layers/antigravityUsageLimits.ts | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/antigravityUsageLimits.ts b/apps/server/src/provider/Layers/antigravityUsageLimits.ts index ad836ad09fb0..dd794f2a3f0e 100644 --- a/apps/server/src/provider/Layers/antigravityUsageLimits.ts +++ b/apps/server/src/provider/Layers/antigravityUsageLimits.ts @@ -1,3 +1,5 @@ +import * as NodeCrypto from "node:crypto"; + import type { AntigravityAuthMethod, ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -22,7 +24,22 @@ const AcpToken = Schema.Struct({ refresh_token: Schema.String, }); const decodeAcpToken = Schema.decodeEffect(Schema.fromJsonString(AcpToken)); -const AccessToken = Schema.Struct({ access_token: Schema.String }); +const AccessToken = Schema.Struct({ + access_token: Schema.String, + id_token: Schema.optional(Schema.String), +}); +const decodeIdTokenClaims = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ sub: Schema.String })), +); + +function googleAccountFingerprint(idToken: string | undefined) { + const payload = idToken?.split(".")[1]; + if (!payload) return undefined; + const claims = decodeIdTokenClaims(Buffer.from(payload, "base64url").toString("utf8")); + return Option.isSome(claims) + ? NodeCrypto.createHash("sha256").update("antigravity\0").update(claims.value.sub).digest("hex") + : undefined; +} const QuotaSummary = Schema.Struct({ groups: Schema.optional( @@ -44,7 +61,11 @@ const QuotaSummary = Schema.Struct({ ), }); -function antigravityQuotaSummaryToLimits(summary: typeof QuotaSummary.Type, checkedAt: string) { +function antigravityQuotaSummaryToLimits( + summary: typeof QuotaSummary.Type, + checkedAt: string, + credentialFingerprint: string | undefined, +) { const windows = (summary.groups ?? []).flatMap((group) => { const scope = group.displayName?.replace(/\s+models$/i, "").trim(); return (group.buckets ?? []).flatMap((bucket): ServerProviderUsageWindow[] => { @@ -53,7 +74,7 @@ function antigravityQuotaSummaryToLimits(summary: typeof QuotaSummary.Type, chec if (!id || remaining === undefined || !Number.isFinite(remaining)) return []; const kind = bucket.window === "weekly" ? "weekly" : bucket.window === "monthly" ? "monthly" : "other"; - const period = kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : undefined; + const period = kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : bucket.window; const reset = bucket.resetTime ? DateTime.make(bucket.resetTime) : Option.none(); return [ { @@ -68,7 +89,10 @@ function antigravityQuotaSummaryToLimits(summary: typeof QuotaSummary.Type, chec }); }); return windows.length > 0 - ? makeUsageLimits({ checkedAt, windows }) + ? { + ...makeUsageLimits({ checkedAt, windows }), + ...(credentialFingerprint ? { credentialFingerprint } : {}), + } : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); } @@ -110,7 +134,11 @@ export const readAntigravityUsageLimits = Effect.fn("readAntigravityUsageLimits" Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(HttpClientResponse.schemaBodyJson(QuotaSummary)), ); - return antigravityQuotaSummaryToLimits(summary, checkedAt); + return antigravityQuotaSummaryToLimits( + summary, + checkedAt, + googleAccountFingerprint(token.id_token), + ); }).pipe( Effect.timeout("15 seconds"), Effect.orElseSucceed(() =>