authorgravatar for 88164545+Maseeek@users.noreply.github.comMaciek Geneja <88164545+Maseeek@users.noreply.github.com> 2026-10-11 03:10:48+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 21:10:48-05:00
log716357cbbaca914ee1cbaef7b9fe3ea79319d1b6
tree4c7604fdd2b07449a839910eb32ec3facf02df96
parent29e09df979955a22e2ad35bd58a13518861b9551
signature Signed by PGP key B5690EEEBB952194

feat(provider-antigravity): support subscription quota and usage limits (#17530)


11 files changed, 739 insertions(+), 4 deletions(-)

apps/mobile/src/features/usage/UsageLimitsPooled.tsx+12-2
......@@ -27,7 +27,11 @@ import { environmentPresentations } from "../../state/presentation";
2727import { ResetCredits } from "./UsageLimitsSection";
2828import { useProviderColors } from "./usageProviders";
2929
30const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
30const DRIVER_LABEL: Partial<Record<string, string>> = {
31 codex: "Codex",
32 claudeAgent: "Claude",
33 antigravity: "Antigravity",
34};
3135const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;
3236
3337function accountName(account: LimitAccount) {
......@@ -264,7 +268,13 @@ export function UsageLimitsSection({
264268 <PoolWindowCard
265269 key={`${window.kind}:${window.id}`}
266270 pool={window}
267 color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}
271 color={
272 pool.driver === "claudeAgent"
273 ? colors.claude
274 : pool.driver === "antigravity"
275 ? colors.antigravity
276 : colors.codex
277 }
268278 now={now}
269279 environmentIds={
270280 selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]
apps/server/src/provider/AntigravityProvider.test.ts+76-1
......@@ -6,6 +6,7 @@ import {
66 ProviderDriverKind,
77 ProviderInstanceId,
88 ProviderSetupError,
9 type ServerProviderUsageLimits,
910} from "@t3tools/contracts";
1011import * as Deferred from "effect/Deferred";
1112import * as Effect from "effect/Effect";
......@@ -128,7 +129,11 @@ const layerTest = ProviderHostLive.layer.pipe(
128129type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError;
129130
130131const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* (
131 options: { readonly enabled?: boolean; readonly safe?: boolean } = {},
132 options: {
133 readonly enabled?: boolean;
134 readonly safe?: boolean;
135 readonly probeUsage?: Effect.Effect<ServerProviderUsageLimits>;
136 } = {},
132137) {
133138 const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>();
134139 const probeCalls = yield* Ref.make(0);
......@@ -141,6 +146,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* (
141146 decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }),
142147 {
143148 stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }),
149 ...(options.probeUsage ? { probeUsage: options.probeUsage } : {}),
144150 probe: Ref.update(probeCalls, (count) => count + 1).pipe(
145151 Effect.andThen(Ref.get(probe)),
146152 Effect.flatten,
......@@ -670,4 +676,73 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => {
670676 }),
671677 ),
672678 );
679
680 it.effect(
681 "publishes limits, retains a successful read on failure, and clears them on sign-out",
682 () =>
683 Effect.scoped(
684 Effect.gen(function* () {
685 const limits: ServerProviderUsageLimits = {
686 checkedAt: "2026-10-09T12:00:00.000Z",
687 windows: [
688 {
689 id: "3p-weekly",
690 kind: "weekly",
691 label: "Claude & GPT (shared) · Weekly",
692 usedPercent: 75,
693 },
694 ],
695 };
696 const usage = yield* Ref.make(limits);
697 const harness = yield* makeHarness({ probeUsage: Ref.get(usage) });
698 yield* harness.initialize;
699 expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits);
700 yield* Ref.set(harness.probe, Effect.succeed(initializeResult));
701 yield* Ref.set(usage, {
702 checkedAt: "2026-10-09T12:01:00.000Z",
703 windows: [],
704 unavailable: { reason: "probeFailed" },
705 });
706 expect((yield* harness.provider.snapshot.refresh).usageLimits).toEqual(limits);
707 yield* harness.provider.onSignedOut;
708 expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined();
709 yield* Ref.set(usage, {
710 checkedAt: "2026-10-09T12:02:00.000Z",
711 windows: [],
712 unavailable: { reason: "unsupported" },
713 });
714 expect((yield* harness.provider.snapshot.refresh).usageLimits?.windows).toEqual([]);
715 }),
716 ),
717 );
718
719 it.effect("a quota read finishing after sign-out cannot restore account limits", () =>
720 Effect.scoped(
721 Effect.gen(function* () {
722 const readStarted = yield* Deferred.make<void>();
723 const readResult = yield* Deferred.make<ServerProviderUsageLimits>();
724 const usage = yield* Ref.make<Effect.Effect<ServerProviderUsageLimits>>(
725 Effect.succeed({ checkedAt: "2026-10-09T12:00:00.000Z", windows: [] }),
726 );
727 const harness = yield* makeHarness({ probeUsage: Ref.get(usage).pipe(Effect.flatten) });
728 yield* harness.initialize;
729 yield* Ref.set(harness.probe, Effect.succeed(initializeResult));
730 yield* Ref.set(
731 usage,
732 Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(readResult))),
733 );
734 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
735 yield* Deferred.await(readStarted);
736 yield* harness.provider.onSignedOut;
737 yield* Deferred.succeed(readResult, {
738 checkedAt: "2026-10-09T12:01:00.000Z",
739 windows: [
740 { id: "gemini-5h", kind: "session", label: "Gemini · 5-hour", usedPercent: 10 },
741 ],
742 });
743 yield* Fiber.join(refresh);
744 expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined();
745 }),
746 ),
747 );
673748});
apps/server/src/provider/AntigravityProvider.ts+22
......@@ -6,6 +6,7 @@ import {
66 type ServerProvider,
77 type ServerProviderModel,
88 type ServerProviderSlashCommand,
9 type ServerProviderUsageLimits,
910} from "@t3tools/contracts";
1011import { createModelCapabilities } from "@t3tools/shared/model";
1112import * as DateTime from "effect/DateTime";
......@@ -29,6 +30,7 @@ import {
2930 isCommandMissingCause,
3031 type ServerProviderDraft,
3132} from "@t3tools/provider-core/server/snapshotProbe";
33import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits";
3234
3335const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
3436const MAX_WORKSPACE_SNAPSHOTS = 32;
......@@ -123,6 +125,8 @@ interface AntigravityProviderOptions {
123125 EffectAcpErrors.AcpError | ProviderSetupError
124126 >;
125127 readonly supportsTextGeneration: Effect.Effect<boolean>;
128 readonly probeUsage?: Effect.Effect<ServerProviderUsageLimits>;
129 readonly clearUsage?: Effect.Effect<void>;
126130 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
127131 /** Auth type and label published once a session authenticates. */
128132 readonly auth?: { readonly type: string; readonly label: string };
......@@ -192,6 +196,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
192196 : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`;
193197 const supportsTextGeneration =
194198 initialized !== undefined ? yield* options.supportsTextGeneration : false;
199 const usageLimits =
200 initialized !== undefined && options.probeUsage ? yield* options.probeUsage : undefined;
195201 const updatedAt = DateTime.formatIso(yield* DateTime.now);
196202 const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => {
197203 if (state.authRevision !== before.authRevision) return state;
......@@ -212,6 +218,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
212218 version: initialized?.agentInfo?.version || draft.version,
213219 status: errorMessage ? "error" : authenticated ? "ready" : "warning",
214220 checkedAt: updatedAt,
221 usageLimits: missingInstallation
222 ? undefined
223 : resolveUsageLimitsAfterProbe({
224 published: draft.usageLimits,
225 probed: usageLimits ?? draft.usageLimits,
226 }),
215227 ...(missingInstallation
216228 ? {
217229 models: [],
......@@ -261,6 +273,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
261273 ) {
262274 const before = yield* SubscriptionRef.get(metadata);
263275 const supportsTextGeneration = yield* options.supportsTextGeneration;
276 const usageLimits =
277 options.probeUsage && (!before.draft.usageLimits || before.draft.usageLimits.unavailable)
278 ? yield* options.probeUsage
279 : before.draft.usageLimits;
264280 const updatedAt = DateTime.formatIso(yield* DateTime.now);
265281 yield* SubscriptionRef.update(metadata, (state) => {
266282 if (
......@@ -285,6 +301,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
285301 label: options.auth?.label ?? "Google account",
286302 },
287303 checkedAt: updatedAt,
304 usageLimits: resolveUsageLimitsAfterProbe({
305 published: draft.usageLimits,
306 probed: usageLimits ?? draft.usageLimits,
307 }),
288308 models: buildAntigravityModelsFromSession(started.sessionSetupResult),
289309 supportsTextGeneration,
290310 ...(cwd
......@@ -350,6 +370,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
350370 });
351371
352372 const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () {
373 if (options.clearUsage) yield* options.clearUsage;
353374 const updatedAt = DateTime.formatIso(yield* DateTime.now);
354375 yield* SubscriptionRef.update(
355376 metadata,
......@@ -367,6 +388,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
367388 skills: [],
368389 workspaceSnapshots: [],
369390 supportsTextGeneration: false,
391 usageLimits: undefined,
370392 },
371393 }) satisfies AntigravityProviderState,
372394 );
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+7
......@@ -15,6 +15,7 @@ import * as Path from "effect/Path";
1515import * as Schema from "effect/Schema";
1616import * as TestClock from "effect/testing/TestClock";
1717import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
18import { HttpClient } from "effect/http";
1819
1920import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2021import * as ServerConfig from "../../config.ts";
......@@ -246,6 +247,12 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {
246247 prefix: "t3-antigravity-driver-config-",
247248}).pipe(
248249 Layer.provideMerge(NodeServices.layer),
250 Layer.provideMerge(
251 Layer.succeed(
252 HttpClient.HttpClient,
253 HttpClient.make(() => Effect.die("Unexpected quota request")),
254 ),
255 ),
249256 Layer.provideMerge(ServerSettings.layerTest()),
250257 Layer.provideMerge(
251258 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
apps/server/src/provider/Drivers/AntigravityDriver.ts+15
......@@ -17,6 +17,7 @@ import * as Schema from "effect/Schema";
1717import * as Scope from "effect/Scope";
1818import * as Stream from "effect/Stream";
1919import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
20import { HttpClient } from "effect/http";
2021import type { AcpError } from "effect-acp/errors";
2122
2223import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
......@@ -53,6 +54,7 @@ import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/Antigr
5354import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
5455import { ProviderDriverError } from "../Errors.ts";
5556import { makeAntigravityProvider } from "../AntigravityProvider.ts";
57import { makeAntigravityUsageProbe } from "../antigravityUsageLimits.ts";
5658import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
5759import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
5860import {
......@@ -74,6 +76,7 @@ export type AntigravityDriverEnv =
7476 | ChildProcessSpawner.ChildProcessSpawner
7577 | Crypto.Crypto
7678 | FileSystem.FileSystem
79 | HttpClient.HttpClient
7780 | IdAllocator.IdAllocatorV2
7881 | McpProviderSessions.McpProviderSessions
7982 | ModelCatalog.ModelCatalog
......@@ -95,6 +98,7 @@ export const AntigravityDriver: ProviderDriver<
9598 Effect.gen(function* () {
9699 const crypto = yield* Crypto.Crypto;
97100 const fileSystem = yield* FileSystem.FileSystem;
101 const httpClient = yield* HttpClient.HttpClient;
98102 const path = yield* Path.Path;
99103 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
100104 const host = yield* ProviderHost.ProviderHost;
......@@ -384,9 +388,20 @@ export const AntigravityDriver: ProviderDriver<
384388 };
385389 });
386390
391 const usage = yield* makeAntigravityUsageProbe({
392 profileDirectory,
393 authMethod: auth.authMethod,
394 }).pipe(
395 Effect.provideService(FileSystem.FileSystem, fileSystem),
396 Effect.provideService(Path.Path, path),
397 Effect.provideService(Crypto.Crypto, crypto),
398 Effect.provideService(HttpClient.HttpClient, httpClient),
399 );
387400 const provider = yield* makeAntigravityProvider(settings, {
388401 stampIdentity: classifyModels,
389402 probe,
403 probeUsage: usage.probe,
404 clearUsage: usage.clear,
390405 auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) },
391406 supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe(
392407 Effect.provideService(FileSystem.FileSystem, fileSystem),
apps/server/src/provider/antigravityUsageLimits.test.ts created+244
......@@ -0,0 +1,244 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
8
9import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts";
10
11const summary = {
12 groups: [
13 {
14 displayName: "Gemini Models",
15 buckets: [
16 {
17 bucketId: "gemini-weekly",
18 window: "weekly",
19 remainingFraction: 0.96,
20 resetTime: "2026-10-12T19:41:43Z",
21 },
22 {
23 bucketId: "gemini-5h",
24 window: "5h",
25 remainingFraction: 0.91,
26 resetTime: "2026-10-09T16:06:01Z",
27 },
28 ],
29 },
30 {
31 displayName: "Claude and GPT models",
32 buckets: [
33 {
34 bucketId: "3p-weekly",
35 window: "weekly",
36 remainingFraction: 0,
37 resetTime: "2026-10-10T15:28:22Z",
38 },
39 {
40 bucketId: "3p-5h",
41 window: "5h",
42 remainingFraction: 0.44,
43 resetTime: "2026-10-09T16:06:30Z",
44 },
45 ],
46 },
47 ],
48};
49
50const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
51 options: {
52 summary?: unknown;
53 models?: unknown;
54 summaryStatus?: number;
55 tokenStatus?: number;
56 missingToken?: boolean;
57 authMethod?: "oauth-personal" | "gemini-api-key";
58 } = {},
59) {
60 const fs = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const profileDirectory = yield* fs.makeTempDirectoryScoped();
63 yield* fs.makeDirectory(path.join(profileDirectory, "antigravity-acp"));
64 const tokenPath = path.join(profileDirectory, "antigravity-acp", "acp_token.json");
65 const writeToken = (refreshToken: string) =>
66 fs.writeFileString(
67 tokenPath,
68 JSON.stringify({
69 client_id: "client-id",
70 client_secret: "client-secret",
71 refresh_token: refreshToken,
72 project_id: "project-id",
73 }),
74 );
75 if (!options.missingToken) yield* writeToken("refresh-a");
76 const requests: Array<{ url: string; body: string }> = [];
77 const http = HttpClient.make((request, _url, _signal, fiber) =>
78 Effect.sync(() => {
79 const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit);
80 expect(init?.redirect).toBe("manual");
81 const body =
82 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
83 requests.push({ url: request.url, body });
84 if (request.url.endsWith("/token")) {
85 expect(new URLSearchParams(body).get("grant_type")).toBe("refresh_token");
86 return HttpClientResponse.fromWeb(
87 request,
88 Response.json(
89 { access_token: "access-token", expires_in: 3600 },
90 { status: options.tokenStatus ?? 200 },
91 ),
92 );
93 }
94 expect(request.headers.authorization).toBe("Bearer access-token");
95 expect(JSON.parse(body)).toEqual({ project: "project-id" });
96 return HttpClientResponse.fromWeb(
97 request,
98 request.url.endsWith(":retrieveUserQuotaSummary")
99 ? Response.json(options.summary ?? summary, { status: options.summaryStatus ?? 200 })
100 : Response.json(options.models ?? { models: {} }),
101 );
102 }),
103 );
104 const usage = yield* makeAntigravityUsageProbe({
105 profileDirectory,
106 authMethod: options.authMethod ?? "oauth-personal",
107 }).pipe(Effect.provideService(HttpClient.HttpClient, http));
108 return { ...usage, requests, writeToken, tokenPath };
109});
110
111describe("Antigravity subscription limits", () => {
112 it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () =>
113 Effect.gen(function* () {
114 const { probe, requests } = yield* fixture();
115 const limits = yield* probe;
116 expect(limits.windows).toHaveLength(4);
117 expect(limits.unavailable).toBeUndefined();
118 expect(limits.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/);
119 const byId = new Map(limits.windows.map((window) => [window.id, window]));
120 expect(byId.get("gemini-weekly")?.usedPercent).toBeCloseTo(4);
121 expect(byId.get("gemini-5h")?.usedPercent).toBeCloseTo(9);
122 expect(byId.get("3p-weekly")).toMatchObject({
123 label: "Claude & GPT (shared) · Weekly",
124 kind: "weekly",
125 usedPercent: 100,
126 windowDurationMins: 10080,
127 resetsAt: "2026-10-10T15:28:22.000Z",
128 });
129 expect(byId.get("3p-5h")).toMatchObject({ kind: "session", windowDurationMins: 300 });
130 expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(56);
131 yield* probe;
132 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1);
133 expect(
134 requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")),
135 ).toHaveLength(2);
136 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
137 );
138
139 it.effect("re-reads changed credentials and clears cached access after sign-out", () =>
140 Effect.gen(function* () {
141 const { probe, clear, requests, writeToken, tokenPath } = yield* fixture();
142 const first = yield* probe;
143 yield* writeToken("refresh-b");
144 const second = yield* probe;
145 expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);
146 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2);
147 yield* (yield* FileSystem.FileSystem).remove(tokenPath);
148 expect((yield* probe).unavailable?.reason).toBe("unsupported");
149 yield* writeToken("refresh-b");
150 yield* probe;
151 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(3);
152 yield* clear;
153 yield* probe;
154 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(4);
155 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
156 );
157
158 it.effect("falls back to model quotas without inventing a window duration", () =>
159 Effect.gen(function* () {
160 const { probe } = yield* fixture({
161 summaryStatus: 404,
162 models: {
163 models: {
164 "gemini-a": {
165 modelProvider: "MODEL_PROVIDER_GOOGLE",
166 quotaInfo: { remainingFraction: 0.8, resetTime: "2026-10-10T12:00:00Z" },
167 },
168 "gemini-b": {
169 modelProvider: "MODEL_PROVIDER_GOOGLE",
170 quotaInfo: { remainingFraction: 0.6, resetTime: "2026-10-10T12:00:00Z" },
171 },
172 "claude-a": { quotaInfo: { remainingFraction: 0.2 } },
173 },
174 },
175 });
176 const limits = yield* probe;
177 expect(limits.windows).toHaveLength(2);
178 expect(limits.windows.find((window) => window.id === "gemini-quota")?.usedPercent).toBe(40);
179 expect(
180 limits.windows.every(
181 (window) => window.kind === "other" && window.windowDurationMins === undefined,
182 ),
183 ).toBe(true);
184 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
185 );
186
187 it.effect("skips disabled and missing fractions and clamps valid percentages", () =>
188 Effect.gen(function* () {
189 const { probe } = yield* fixture({
190 summary: {
191 buckets: [
192 { bucketId: "disabled", remainingFraction: 0, disabled: true },
193 { bucketId: "missing", resetTime: "2026-10-10T12:00:00Z" },
194 { bucketId: "gemini-5h", window: "5h", remainingFraction: 2, resetTime: "invalid" },
195 ],
196 },
197 });
198 expect((yield* probe).windows).toEqual([
199 {
200 id: "gemini-5h",
201 kind: "session",
202 label: "Gemini · 5-hour",
203 usedPercent: 0,
204 windowDurationMins: 300,
205 },
206 ]);
207 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
208 );
209
210 it.effect("preserves cached access token when quota endpoints return no usable windows", () =>
211 Effect.gen(function* () {
212 const { probe, requests } = yield* fixture({
213 summary: { groups: [] },
214 models: { models: {} },
215 });
216 const first = yield* probe;
217 expect(first.unavailable?.reason).toBe("probeFailed");
218 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1);
219 const second = yield* probe;
220 expect(second.unavailable?.reason).toBe("probeFailed");
221 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1);
222 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
223 );
224
225 it.effect("returns a safe failure when OAuth refresh fails", () =>
226 Effect.gen(function* () {
227 const { probe } = yield* fixture({ tokenStatus: 401 });
228 const limits = yield* probe;
229 expect(limits.windows).toEqual([]);
230 expect(limits.unavailable?.reason).toBe("probeFailed");
231 expect(JSON.stringify(limits)).not.toMatch(/client-secret|refresh-a|access-token/);
232 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
233 );
234
235 it.effect("does not make network requests for API keys or missing sign-ins", () =>
236 Effect.gen(function* () {
237 for (const options of [{ authMethod: "gemini-api-key" as const }, { missingToken: true }]) {
238 const { probe, requests } = yield* fixture(options);
239 expect((yield* probe).unavailable?.reason).toBe("unsupported");
240 expect(requests).toEqual([]);
241 }
242 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
243 );
244});
apps/server/src/provider/antigravityUsageLimits.ts created+283
......@@ -0,0 +1,283 @@
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 Quota = Schema.Struct({
34 remainingFraction: Schema.optional(Schema.Number),
35 resetTime: Schema.optional(Schema.String),
36});
37const Bucket = Schema.Struct({
38 ...Quota.fields,
39 bucketId: Schema.NonEmptyString,
40 window: Schema.optional(Schema.String),
41 displayName: Schema.optional(Schema.String),
42 disabled: Schema.optional(Schema.Boolean),
43});
44const Summary = Schema.Struct({
45 groups: Schema.optional(
46 Schema.Array(
47 Schema.Struct({
48 displayName: Schema.optional(Schema.String),
49 buckets: Schema.optional(Schema.Array(Bucket)),
50 }),
51 ),
52 ),
53 buckets: Schema.optional(Schema.Array(Bucket)),
54});
55const Models = Schema.Struct({
56 models: Schema.Record(
57 Schema.String,
58 Schema.Struct({
59 modelProvider: Schema.optional(Schema.String),
60 quotaInfo: Schema.optional(Quota),
61 weeklyQuotaInfo: Schema.optional(Quota),
62 }),
63 ),
64});
65
66const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile));
67const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
68const decodeSummary = Schema.decodeUnknownEffect(Summary);
69const decodeModels = Schema.decodeUnknownEffect(Models);
70
71function iso(value: string | undefined): string | undefined {
72 const parsed = value ? DateTime.make(value) : Option.none();
73 return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : undefined;
74}
75
76function quotaWindow(
77 id: string,
78 label: string,
79 quota: typeof Quota.Type,
80 kind: ServerProviderUsageWindow["kind"] = "other",
81 windowDurationMins?: number,
82): ServerProviderUsageWindow | undefined {
83 if (quota.remainingFraction === undefined || !Number.isFinite(quota.remainingFraction)) {
84 return undefined;
85 }
86 const resetsAt = iso(quota.resetTime);
87 return {
88 id,
89 label,
90 kind,
91 usedPercent: (1 - Math.max(0, Math.min(1, quota.remainingFraction))) * 100,
92 ...(resetsAt ? { resetsAt } : {}),
93 ...(windowDurationMins === undefined ? {} : { windowDurationMins }),
94 };
95}
96
97/** The summary names the actual shared buckets; Claude and GPT consume one allowance. */
98function summaryWindows(summary: typeof Summary.Type) {
99 const windows = new Map<string, ServerProviderUsageWindow>();
100 const groups = summary.groups?.some((group) => group.buckets?.length)
101 ? summary.groups
102 : [{ buckets: summary.buckets }];
103 for (const group of groups ?? []) {
104 for (const bucket of group.buckets ?? []) {
105 if (bucket.disabled) continue;
106 const family = bucket.bucketId.startsWith("gemini-")
107 ? "Gemini"
108 : bucket.bucketId.startsWith("3p-")
109 ? "Claude & GPT (shared)"
110 : group.displayName?.trim() || bucket.bucketId;
111 const weekly = bucket.window === "weekly";
112 const session = bucket.window === "5h";
113 const window = quotaWindow(
114 bucket.bucketId,
115 `${family} · ${weekly ? "Weekly" : session ? "5-hour" : bucket.displayName?.trim() || "Quota"}`,
116 bucket,
117 weekly ? "weekly" : session ? "session" : "other",
118 weekly ? 7 * 24 * 60 : session ? 5 * 60 : undefined,
119 );
120 if (window) windows.set(window.id, window);
121 }
122 }
123 return [...windows.values()];
124}
125
126/** Older accounts expose model quotas without a window length. Do not invent a reset cadence. */
127function modelWindows(models: typeof Models.Type) {
128 const windows = new Map<string, ServerProviderUsageWindow>();
129 for (const [modelId, model] of Object.entries(models.models)) {
130 const family =
131 model.modelProvider === "MODEL_PROVIDER_GOOGLE" || modelId.startsWith("gemini-")
132 ? "Gemini"
133 : model.modelProvider === "MODEL_PROVIDER_ANTHROPIC" || modelId.startsWith("claude-")
134 ? "Claude"
135 : model.modelProvider === "MODEL_PROVIDER_OPENAI" || modelId.startsWith("gpt-")
136 ? "GPT"
137 : undefined;
138 if (!family) continue;
139 for (const [suffix, quota] of [
140 ["quota", model.quotaInfo],
141 ["weekly", model.weeklyQuotaInfo],
142 ] as const) {
143 if (!quota) continue;
144 const window = quotaWindow(
145 `${family.toLowerCase()}-${suffix}`,
146 `${family} · ${suffix === "weekly" ? "Weekly" : "Reported quota"}`,
147 quota,
148 suffix === "weekly" ? "weekly" : "other",
149 suffix === "weekly" ? 7 * 24 * 60 : undefined,
150 );
151 if (!window) continue;
152 const previous = windows.get(window.id);
153 if (!previous || window.usedPercent > previous.usedPercent) windows.set(window.id, window);
154 }
155 }
156 return [...windows.values()];
157}
158
159/** Reads this instance's Google profile, with a memory-only access token and no credential writes. */
160export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(function* (input: {
161 readonly profileDirectory: string;
162 readonly authMethod: AntigravitySettings["authMethod"];
163}) {
164 const fs = yield* FileSystem.FileSystem;
165 const path = yield* Path.Path;
166 const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
167 const crypto = yield* Crypto.Crypto;
168 const platform = yield* HostProcess.Platform;
169 const architecture = yield* HostProcess.Architecture;
170 const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json");
171 let cached: { fingerprint: string; accessToken: string; expiresAt: number } | undefined;
172 let generation = 0;
173
174 const request = (request: HttpClientRequest.HttpClientRequest) =>
175 client.execute(request).pipe(
176 Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),
177 Effect.flatMap(HttpClientResponse.filterStatusOk),
178 Effect.flatMap((response) => response.json),
179 Effect.timeout("10 seconds"),
180 );
181 const read = Effect.gen(function* () {
182 const startedGeneration = generation;
183 const checkedAt = DateTime.formatIso(yield* DateTime.now);
184 if (input.authMethod !== "oauth-personal" && input.authMethod !== "oauth-business") {
185 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
186 }
187 if (!(yield* fs.exists(tokenPath))) {
188 cached = undefined;
189 return makeUnavailableUsageLimits({
190 checkedAt,
191 reason: "unsupported",
192 message: "Sign in with Google to see Antigravity limits.",
193 });
194 }
195 const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));
196 const fingerprint = Array.from(
197 yield* crypto.digest(
198 "SHA-256",
199 new TextEncoder().encode(
200 `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
201 ),
202 ),
203 (byte) => byte.toString(16).padStart(2, "0"),
204 ).join("");
205 const now = yield* Clock.currentTimeMillis;
206 let accessToken: string;
207 if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
208 cached = undefined;
209 const refreshed = yield* request(
210 HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe(
211 HttpClientRequest.bodyUrlParams({
212 client_id: token.client_id,
213 client_secret: token.client_secret,
214 refresh_token: token.refresh_token,
215 grant_type: "refresh_token",
216 }),
217 ),
218 ).pipe(Effect.flatMap(decodeTokenResponse));
219 accessToken = refreshed.access_token;
220 if (generation === startedGeneration) {
221 cached = { fingerprint, accessToken, expiresAt: now + refreshed.expires_in * 1000 };
222 }
223 } else {
224 accessToken = cached.accessToken;
225 }
226 const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(
227 operation: string,
228 parse: (response: unknown) => Effect.Effect<ServerProviderUsageWindow[], E>,
229 ) {
230 for (const endpoint of ENDPOINTS) {
231 const windows = yield* request(
232 HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe(
233 HttpClientRequest.setHeaders({
234 Authorization: `Bearer ${accessToken}`,
235 "User-Agent": `antigravity/hub/1.0.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`,
236 }),
237 HttpClientRequest.bodyJsonUnsafe(token.project_id ? { project: token.project_id } : {}),
238 ),
239 ).pipe(
240 Effect.flatMap(parse),
241 Effect.orElseSucceed(() => []),
242 );
243 if (windows.length > 0) return windows;
244 }
245 return [];
246 });
247 const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response) =>
248 decodeSummary(response).pipe(Effect.map(summaryWindows)),
249 );
250 const windows = summary.length
251 ? summary
252 : yield* fetchWindows("fetchAvailableModels", (response) =>
253 decodeModels(response).pipe(Effect.map(modelWindows)),
254 );
255 if (windows.length === 0) {
256 return makeUnavailableUsageLimits({
257 checkedAt,
258 reason: "probeFailed",
259 message: "Antigravity did not return usable quota information.",
260 });
261 }
262 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: fingerprint };
263 });
264 const probe = read.pipe(
265 Effect.timeout("30 seconds"),
266 Effect.catch(() =>
267 Effect.gen(function* () {
268 return makeUnavailableUsageLimits({
269 checkedAt: DateTime.formatIso(yield* DateTime.now),
270 reason: "probeFailed",
271 message: "Could not read Antigravity limits. Refresh provider status to retry.",
272 });
273 }),
274 ),
275 );
276 return {
277 probe,
278 clear: Effect.sync(() => {
279 cached = undefined;
280 generation++;
281 }),
282 };
283});
apps/web/src/components/usage/UsageLimits.tsx+7-1
......@@ -49,7 +49,13 @@ const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof Ga
4949/** The series colour the cost chart uses for this driver, so the two views read as one. */
5050export function barColor(driver: ServerProvider["driver"]): string {
5151 const kind: UsageProviderKind | undefined =
52 driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined;
52 driver === "codex"
53 ? "codex"
54 : driver === "claudeAgent"
55 ? "claude"
56 : driver === "antigravity"
57 ? "antigravity"
58 : undefined;
5359 return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)";
5460}
5561
docs/user/providers-antigravity.md+4
......@@ -125,6 +125,10 @@ is refused while the runtime is in use.
125125
126126## Check access and troubleshoot
127127
128Open **Usage > Limits** to see the remaining Gemini and shared Claude/GPT allowances
129for Google sign-ins. Each group has separate five-hour and weekly limits and reset
130times. Limits refresh with provider status and include usage outside T3 Code.
131
128132A server restart keeps your Google sign-in. The provider shows the saved account
129133until a session, a refresh, or a sign-out reports something new.
130134
packages/shared/src/usageLimits.test.ts+58
......@@ -1395,3 +1395,61 @@ describe("ChatGPT sharing presentation", () => {
13951395 ).toBe(false);
13961396 });
13971397});
1398
1399describe("Antigravity limit presentation", () => {
1400 it("keeps the shared Claude/GPT bucket separate from Gemini and pairs their windows", () => {
1401 const antigravity = provider({
1402 instanceId: ProviderInstanceId.make("antigravity"),
1403 driver: ProviderDriverKind.make("antigravity"),
1404 usageLimits: {
1405 checkedAt: "2026-10-09T12:00:00.000Z",
1406 windows: [
1407 { ...window, id: "3p-5h", label: "Claude & GPT (shared) · 5-hour", usedPercent: 90 },
1408 { ...window, id: "gemini-5h", label: "Gemini · 5-hour", usedPercent: 20 },
1409 {
1410 ...window,
1411 id: "3p-weekly",
1412 kind: "weekly",
1413 label: "Claude & GPT (shared) · Weekly",
1414 usedPercent: 100,
1415 },
1416 {
1417 ...window,
1418 id: "gemini-weekly",
1419 kind: "weekly",
1420 label: "Gemini · Weekly",
1421 usedPercent: 10,
1422 },
1423 ],
1424 },
1425 });
1426 const accounts = collectLimitAccounts(
1427 new Map([
1428 [
1429 EnvironmentId.make("a"),
1430 {
1431 entry: { target: { label: "Local" } },
1432 serverConfig: {
1433 providers: [
1434 provider({
1435 usageLimits: { checkedAt: "2026-10-09T12:00:00.000Z", windows: [window] },
1436 }),
1437 antigravity,
1438 ],
1439 },
1440 },
1441 ],
1442 ]),
1443 );
1444 const pools = collectLimitPools(accounts, now);
1445 expect(pools.map((pool) => pool.driver)).toEqual(["codex", "antigravity"]);
1446 const windows = displayLimitWindows(pools[1]!);
1447 expect(windows.map((window) => window.id)).toEqual([
1448 "gemini-5h",
1449 "gemini-weekly",
1450 "3p-5h",
1451 "3p-weekly",
1452 ]);
1453 expect(windows.map((window) => window.remainingPercent)).toEqual([80, 90, 10, 0]);
1454 });
1455});
packages/shared/src/usageLimits.ts+11
......@@ -397,6 +397,17 @@ export interface LimitPool {
397397
398398/** Show Cursor's two usable pools instead of a combined percentage when both are available. */
399399export function displayLimitWindows(pool: LimitPool) {
400 if (pool.driver === "antigravity") {
401 // Keep each model group's five-hour and weekly buckets next to one another.
402 return [...pool.windows].sort(
403 (left, right) =>
404 Number(!left.id.startsWith("gemini-")) - Number(!right.id.startsWith("gemini-")) ||
405 left.id
406 .replace(/-(5h|weekly|quota)$/, "")
407 .localeCompare(right.id.replace(/-(5h|weekly|quota)$/, "")) ||
408 WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
409 );
410 }
400411 if (pool.driver !== "cursor") return pool.windows;
401412 const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed");
402413 const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed");