diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index ae2938476559..1dc9fc704336 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -27,7 +27,11 @@ import { environmentPresentations } from "../../state/presentation"; import { ResetCredits } from "./UsageLimitsSection"; import { useProviderColors } from "./usageProviders"; -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; +const DRIVER_LABEL: Partial> = { + codex: "Codex", + claudeAgent: "Claude", + antigravity: "Antigravity", +}; const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; function accountName(account: LimitAccount) { @@ -264,7 +268,13 @@ export function UsageLimitsSection({ ; + } = {}, ) { const initialProbe = yield* Deferred.make(); const probeCalls = yield* Ref.make(0); @@ -141,6 +146,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), { stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + ...(options.probeUsage ? { probeUsage: options.probeUsage } : {}), probe: Ref.update(probeCalls, (count) => count + 1).pipe( Effect.andThen(Ref.get(probe)), Effect.flatten, @@ -670,4 +676,73 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }), ), ); + + it.effect( + "publishes limits, retains a successful read on failure, and clears them on sign-out", + () => + Effect.scoped( + Effect.gen(function* () { + const limits: ServerProviderUsageLimits = { + checkedAt: "2026-10-09T12:00:00.000Z", + windows: [ + { + id: "3p-weekly", + kind: "weekly", + label: "Claude & GPT (shared) · Weekly", + usedPercent: 75, + }, + ], + }; + const usage = yield* Ref.make(limits); + const harness = yield* makeHarness({ probeUsage: Ref.get(usage) }); + yield* harness.initialize; + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits); + yield* Ref.set(harness.probe, Effect.succeed(initializeResult)); + yield* Ref.set(usage, { + checkedAt: "2026-10-09T12:01:00.000Z", + windows: [], + unavailable: { reason: "probeFailed" }, + }); + expect((yield* harness.provider.snapshot.refresh).usageLimits).toEqual(limits); + yield* harness.provider.onSignedOut; + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); + yield* Ref.set(usage, { + checkedAt: "2026-10-09T12:02:00.000Z", + windows: [], + unavailable: { reason: "unsupported" }, + }); + expect((yield* harness.provider.snapshot.refresh).usageLimits?.windows).toEqual([]); + }), + ), + ); + + it.effect("a quota read finishing after sign-out cannot restore account limits", () => + Effect.scoped( + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const readResult = yield* Deferred.make(); + const usage = yield* Ref.make>( + Effect.succeed({ checkedAt: "2026-10-09T12:00:00.000Z", windows: [] }), + ); + const harness = yield* makeHarness({ probeUsage: Ref.get(usage).pipe(Effect.flatten) }); + yield* harness.initialize; + yield* Ref.set(harness.probe, Effect.succeed(initializeResult)); + yield* Ref.set( + usage, + Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(readResult))), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(readStarted); + yield* harness.provider.onSignedOut; + yield* Deferred.succeed(readResult, { + checkedAt: "2026-10-09T12:01:00.000Z", + windows: [ + { id: "gemini-5h", kind: "session", label: "Gemini · 5-hour", usedPercent: 10 }, + ], + }); + yield* Fiber.join(refresh); + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); + }), + ), + ); }); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ce4663a9be41..59ebbeae3998 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/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 "@t3tools/provider-core/server/snapshotProbe"; +import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits"; const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; @@ -123,6 +125,8 @@ interface AntigravityProviderOptions { EffectAcpErrors.AcpError | ProviderSetupError >; readonly supportsTextGeneration: Effect.Effect; + readonly probeUsage?: Effect.Effect; + readonly clearUsage?: Effect.Effect; readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; /** Auth type and label published once a session authenticates. */ readonly auth?: { readonly type: string; readonly label: string }; @@ -192,6 +196,8 @@ 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 usageLimits = + initialized !== undefined && options.probeUsage ? yield* options.probeUsage : undefined; const updatedAt = DateTime.formatIso(yield* DateTime.now); const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { if (state.authRevision !== before.authRevision) return state; @@ -212,6 +218,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func version: initialized?.agentInfo?.version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, + usageLimits: missingInstallation + ? undefined + : resolveUsageLimitsAfterProbe({ + published: draft.usageLimits, + probed: usageLimits ?? draft.usageLimits, + }), ...(missingInstallation ? { models: [], @@ -261,6 +273,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ) { const before = yield* SubscriptionRef.get(metadata); const supportsTextGeneration = yield* options.supportsTextGeneration; + const usageLimits = + options.probeUsage && (!before.draft.usageLimits || before.draft.usageLimits.unavailable) + ? yield* options.probeUsage + : before.draft.usageLimits; const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update(metadata, (state) => { if ( @@ -285,6 +301,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func label: options.auth?.label ?? "Google account", }, checkedAt: updatedAt, + usageLimits: resolveUsageLimitsAfterProbe({ + published: draft.usageLimits, + probed: usageLimits ?? draft.usageLimits, + }), models: buildAntigravityModelsFromSession(started.sessionSetupResult), supportsTextGeneration, ...(cwd @@ -350,6 +370,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { + if (options.clearUsage) yield* options.clearUsage; const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update( metadata, @@ -367,6 +388,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func skills: [], workspaceSnapshots: [], supportsTextGeneration: false, + usageLimits: undefined, }, }) satisfies AntigravityProviderState, ); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index f15cdfeff676..ba40cf0ef836 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { HttpClient } from "effect/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; @@ -246,6 +247,12 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-driver-config-", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected quota request")), + ), + ), Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge( Layer.mock(BackgroundPolicy.BackgroundPolicy)({ diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index c82565e8f2fe..5317849cfa47 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -17,6 +17,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { HttpClient } from "effect/http"; import type { AcpError } from "effect-acp/errors"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -53,6 +54,7 @@ import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/Antigr import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; +import { makeAntigravityUsageProbe } from "../antigravityUsageLimits.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { @@ -74,6 +76,7 @@ export type AntigravityDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem + | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 | McpProviderSessions.McpProviderSessions | ModelCatalog.ModelCatalog @@ -95,6 +98,7 @@ export const AntigravityDriver: ProviderDriver< Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const host = yield* ProviderHost.ProviderHost; @@ -384,9 +388,20 @@ export const AntigravityDriver: ProviderDriver< }; }); + const usage = yield* makeAntigravityUsageProbe({ + profileDirectory, + authMethod: auth.authMethod, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); const provider = yield* makeAntigravityProvider(settings, { stampIdentity: classifyModels, probe, + probeUsage: usage.probe, + clearUsage: usage.clear, auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts new file mode 100644 index 000000000000..77d6be133143 --- /dev/null +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -0,0 +1,244 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; + +import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts"; + +const summary = { + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-weekly", + window: "weekly", + remainingFraction: 0.96, + resetTime: "2026-10-12T19:41:43Z", + }, + { + bucketId: "gemini-5h", + window: "5h", + remainingFraction: 0.91, + resetTime: "2026-10-09T16:06:01Z", + }, + ], + }, + { + displayName: "Claude and GPT models", + buckets: [ + { + bucketId: "3p-weekly", + window: "weekly", + remainingFraction: 0, + resetTime: "2026-10-10T15:28:22Z", + }, + { + bucketId: "3p-5h", + window: "5h", + remainingFraction: 0.44, + resetTime: "2026-10-09T16:06:30Z", + }, + ], + }, + ], +}; + +const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( + options: { + summary?: unknown; + models?: unknown; + summaryStatus?: number; + tokenStatus?: number; + missingToken?: boolean; + authMethod?: "oauth-personal" | "gemini-api-key"; + } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profileDirectory = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(profileDirectory, "antigravity-acp")); + const tokenPath = path.join(profileDirectory, "antigravity-acp", "acp_token.json"); + const writeToken = (refreshToken: string) => + fs.writeFileString( + tokenPath, + JSON.stringify({ + client_id: "client-id", + client_secret: "client-secret", + refresh_token: refreshToken, + project_id: "project-id", + }), + ); + if (!options.missingToken) yield* writeToken("refresh-a"); + const requests: Array<{ url: string; body: string }> = []; + const http = HttpClient.make((request, _url, _signal, fiber) => + Effect.sync(() => { + const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit); + expect(init?.redirect).toBe("manual"); + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ url: request.url, body }); + if (request.url.endsWith("/token")) { + expect(new URLSearchParams(body).get("grant_type")).toBe("refresh_token"); + return HttpClientResponse.fromWeb( + request, + Response.json( + { access_token: "access-token", expires_in: 3600 }, + { status: options.tokenStatus ?? 200 }, + ), + ); + } + expect(request.headers.authorization).toBe("Bearer access-token"); + expect(JSON.parse(body)).toEqual({ project: "project-id" }); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith(":retrieveUserQuotaSummary") + ? Response.json(options.summary ?? summary, { status: options.summaryStatus ?? 200 }) + : Response.json(options.models ?? { models: {} }), + ); + }), + ); + const usage = yield* makeAntigravityUsageProbe({ + profileDirectory, + authMethod: options.authMethod ?? "oauth-personal", + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + return { ...usage, requests, writeToken, tokenPath }; +}); + +describe("Antigravity subscription limits", () => { + it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () => + Effect.gen(function* () { + const { probe, requests } = yield* fixture(); + const limits = yield* probe; + expect(limits.windows).toHaveLength(4); + expect(limits.unavailable).toBeUndefined(); + expect(limits.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/); + const byId = new Map(limits.windows.map((window) => [window.id, window])); + expect(byId.get("gemini-weekly")?.usedPercent).toBeCloseTo(4); + expect(byId.get("gemini-5h")?.usedPercent).toBeCloseTo(9); + expect(byId.get("3p-weekly")).toMatchObject({ + label: "Claude & GPT (shared) · Weekly", + kind: "weekly", + usedPercent: 100, + windowDurationMins: 10080, + resetsAt: "2026-10-10T15:28:22.000Z", + }); + expect(byId.get("3p-5h")).toMatchObject({ kind: "session", windowDurationMins: 300 }); + expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(56); + yield* probe; + expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); + expect( + requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")), + ).toHaveLength(2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("re-reads changed credentials and clears cached access after sign-out", () => + Effect.gen(function* () { + const { probe, clear, requests, writeToken, tokenPath } = yield* fixture(); + const first = yield* probe; + yield* writeToken("refresh-b"); + const second = yield* probe; + expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint); + expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2); + yield* (yield* FileSystem.FileSystem).remove(tokenPath); + expect((yield* probe).unavailable?.reason).toBe("unsupported"); + yield* writeToken("refresh-b"); + yield* probe; + expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(3); + yield* clear; + yield* probe; + expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(4); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to model quotas without inventing a window duration", () => + Effect.gen(function* () { + const { probe } = yield* fixture({ + summaryStatus: 404, + models: { + models: { + "gemini-a": { + modelProvider: "MODEL_PROVIDER_GOOGLE", + quotaInfo: { remainingFraction: 0.8, resetTime: "2026-10-10T12:00:00Z" }, + }, + "gemini-b": { + modelProvider: "MODEL_PROVIDER_GOOGLE", + quotaInfo: { remainingFraction: 0.6, resetTime: "2026-10-10T12:00:00Z" }, + }, + "claude-a": { quotaInfo: { remainingFraction: 0.2 } }, + }, + }, + }); + const limits = yield* probe; + expect(limits.windows).toHaveLength(2); + expect(limits.windows.find((window) => window.id === "gemini-quota")?.usedPercent).toBe(40); + expect( + limits.windows.every( + (window) => window.kind === "other" && window.windowDurationMins === undefined, + ), + ).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("skips disabled and missing fractions and clamps valid percentages", () => + Effect.gen(function* () { + const { probe } = yield* fixture({ + summary: { + buckets: [ + { bucketId: "disabled", remainingFraction: 0, disabled: true }, + { bucketId: "missing", resetTime: "2026-10-10T12:00:00Z" }, + { bucketId: "gemini-5h", window: "5h", remainingFraction: 2, resetTime: "invalid" }, + ], + }, + }); + expect((yield* probe).windows).toEqual([ + { + id: "gemini-5h", + kind: "session", + label: "Gemini · 5-hour", + usedPercent: 0, + windowDurationMins: 300, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves cached access token when quota endpoints return no usable windows", () => + Effect.gen(function* () { + const { probe, requests } = yield* fixture({ + summary: { groups: [] }, + models: { models: {} }, + }); + const first = yield* probe; + expect(first.unavailable?.reason).toBe("probeFailed"); + expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); + const second = yield* probe; + expect(second.unavailable?.reason).toBe("probeFailed"); + expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("returns a safe failure when OAuth refresh fails", () => + Effect.gen(function* () { + const { probe } = yield* fixture({ tokenStatus: 401 }); + const limits = yield* probe; + expect(limits.windows).toEqual([]); + expect(limits.unavailable?.reason).toBe("probeFailed"); + expect(JSON.stringify(limits)).not.toMatch(/client-secret|refresh-a|access-token/); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not make network requests for API keys or missing sign-ins", () => + Effect.gen(function* () { + for (const options of [{ authMethod: "gemini-api-key" as const }, { missingToken: true }]) { + const { probe, requests } = yield* fixture(options); + expect((yield* probe).unavailable?.reason).toBe("unsupported"); + expect(requests).toEqual([]); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts new file mode 100644 index 000000000000..f0d7f8ead52f --- /dev/null +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -0,0 +1,283 @@ +import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +import { + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; + +const ENDPOINTS = [ + "https://daily-cloudcode-pa.googleapis.com", + "https://cloudcode-pa.googleapis.com", +]; +const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; +const TokenFile = Schema.Struct({ + client_id: Schema.NonEmptyString, + client_secret: Schema.NonEmptyString, + refresh_token: Schema.NonEmptyString, + project_id: Schema.optional(Schema.String), +}); +const TokenResponse = Schema.Struct({ + access_token: Schema.NonEmptyString, + expires_in: Schema.Number.check(Schema.isGreaterThan(0)), +}); +const Quota = Schema.Struct({ + remainingFraction: Schema.optional(Schema.Number), + resetTime: Schema.optional(Schema.String), +}); +const Bucket = Schema.Struct({ + ...Quota.fields, + bucketId: Schema.NonEmptyString, + window: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + disabled: Schema.optional(Schema.Boolean), +}); +const Summary = Schema.Struct({ + groups: Schema.optional( + Schema.Array( + Schema.Struct({ + displayName: Schema.optional(Schema.String), + buckets: Schema.optional(Schema.Array(Bucket)), + }), + ), + ), + buckets: Schema.optional(Schema.Array(Bucket)), +}); +const Models = Schema.Struct({ + models: Schema.Record( + Schema.String, + Schema.Struct({ + modelProvider: Schema.optional(Schema.String), + quotaInfo: Schema.optional(Quota), + weeklyQuotaInfo: Schema.optional(Quota), + }), + ), +}); + +const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile)); +const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); +const decodeSummary = Schema.decodeUnknownEffect(Summary); +const decodeModels = Schema.decodeUnknownEffect(Models); + +function iso(value: string | undefined): string | undefined { + const parsed = value ? DateTime.make(value) : Option.none(); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : undefined; +} + +function quotaWindow( + id: string, + label: string, + quota: typeof Quota.Type, + kind: ServerProviderUsageWindow["kind"] = "other", + windowDurationMins?: number, +): ServerProviderUsageWindow | undefined { + if (quota.remainingFraction === undefined || !Number.isFinite(quota.remainingFraction)) { + return undefined; + } + const resetsAt = iso(quota.resetTime); + return { + id, + label, + kind, + usedPercent: (1 - Math.max(0, Math.min(1, quota.remainingFraction))) * 100, + ...(resetsAt ? { resetsAt } : {}), + ...(windowDurationMins === undefined ? {} : { windowDurationMins }), + }; +} + +/** The summary names the actual shared buckets; Claude and GPT consume one allowance. */ +function summaryWindows(summary: typeof Summary.Type) { + const windows = new Map(); + const groups = summary.groups?.some((group) => group.buckets?.length) + ? summary.groups + : [{ buckets: summary.buckets }]; + for (const group of groups ?? []) { + for (const bucket of group.buckets ?? []) { + if (bucket.disabled) continue; + const family = bucket.bucketId.startsWith("gemini-") + ? "Gemini" + : bucket.bucketId.startsWith("3p-") + ? "Claude & GPT (shared)" + : group.displayName?.trim() || bucket.bucketId; + const weekly = bucket.window === "weekly"; + const session = bucket.window === "5h"; + const window = quotaWindow( + bucket.bucketId, + `${family} · ${weekly ? "Weekly" : session ? "5-hour" : bucket.displayName?.trim() || "Quota"}`, + bucket, + weekly ? "weekly" : session ? "session" : "other", + weekly ? 7 * 24 * 60 : session ? 5 * 60 : undefined, + ); + if (window) windows.set(window.id, window); + } + } + return [...windows.values()]; +} + +/** Older accounts expose model quotas without a window length. Do not invent a reset cadence. */ +function modelWindows(models: typeof Models.Type) { + const windows = new Map(); + for (const [modelId, model] of Object.entries(models.models)) { + const family = + model.modelProvider === "MODEL_PROVIDER_GOOGLE" || modelId.startsWith("gemini-") + ? "Gemini" + : model.modelProvider === "MODEL_PROVIDER_ANTHROPIC" || modelId.startsWith("claude-") + ? "Claude" + : model.modelProvider === "MODEL_PROVIDER_OPENAI" || modelId.startsWith("gpt-") + ? "GPT" + : undefined; + if (!family) continue; + for (const [suffix, quota] of [ + ["quota", model.quotaInfo], + ["weekly", model.weeklyQuotaInfo], + ] as const) { + if (!quota) continue; + const window = quotaWindow( + `${family.toLowerCase()}-${suffix}`, + `${family} · ${suffix === "weekly" ? "Weekly" : "Reported quota"}`, + quota, + suffix === "weekly" ? "weekly" : "other", + suffix === "weekly" ? 7 * 24 * 60 : undefined, + ); + if (!window) continue; + const previous = windows.get(window.id); + if (!previous || window.usedPercent > previous.usedPercent) windows.set(window.id, window); + } + } + return [...windows.values()]; +} + +/** Reads this instance's Google profile, with a memory-only access token and no credential writes. */ +export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(function* (input: { + readonly profileDirectory: string; + readonly authMethod: AntigravitySettings["authMethod"]; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const crypto = yield* Crypto.Crypto; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; + const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json"); + let cached: { fingerprint: string; accessToken: string; expiresAt: number } | undefined; + let generation = 0; + + const request = (request: HttpClientRequest.HttpClientRequest) => + client.execute(request).pipe( + Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout("10 seconds"), + ); + const read = Effect.gen(function* () { + const startedGeneration = generation; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (input.authMethod !== "oauth-personal" && input.authMethod !== "oauth-business") { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + if (!(yield* fs.exists(tokenPath))) { + cached = undefined; + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Sign in with Google to see Antigravity limits.", + }); + } + const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile)); + const fingerprint = Array.from( + yield* crypto.digest( + "SHA-256", + new TextEncoder().encode( + `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`, + ), + ), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + const now = yield* Clock.currentTimeMillis; + let accessToken: string; + if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) { + cached = undefined; + const refreshed = yield* request( + HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe( + HttpClientRequest.bodyUrlParams({ + client_id: token.client_id, + client_secret: token.client_secret, + refresh_token: token.refresh_token, + grant_type: "refresh_token", + }), + ), + ).pipe(Effect.flatMap(decodeTokenResponse)); + accessToken = refreshed.access_token; + if (generation === startedGeneration) { + cached = { fingerprint, accessToken, expiresAt: now + refreshed.expires_in * 1000 }; + } + } else { + accessToken = cached.accessToken; + } + const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( + operation: string, + parse: (response: unknown) => Effect.Effect, + ) { + for (const endpoint of ENDPOINTS) { + const windows = yield* request( + HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${accessToken}`, + "User-Agent": `antigravity/hub/1.0.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`, + }), + HttpClientRequest.bodyJsonUnsafe(token.project_id ? { project: token.project_id } : {}), + ), + ).pipe( + Effect.flatMap(parse), + Effect.orElseSucceed(() => []), + ); + if (windows.length > 0) return windows; + } + return []; + }); + const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response) => + decodeSummary(response).pipe(Effect.map(summaryWindows)), + ); + const windows = summary.length + ? summary + : yield* fetchWindows("fetchAvailableModels", (response) => + decodeModels(response).pipe(Effect.map(modelWindows)), + ); + if (windows.length === 0) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Antigravity did not return usable quota information.", + }); + } + return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: fingerprint }; + }); + const probe = read.pipe( + Effect.timeout("30 seconds"), + Effect.catch(() => + Effect.gen(function* () { + return makeUnavailableUsageLimits({ + checkedAt: DateTime.formatIso(yield* DateTime.now), + reason: "probeFailed", + message: "Could not read Antigravity limits. Refresh provider status to retry.", + }); + }), + ), + ); + return { + probe, + clear: Effect.sync(() => { + cached = undefined; + generation++; + }), + }; +}); diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 14720d73c800..45e567f346cb 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -49,7 +49,13 @@ const PACE: Record Limits** to see the remaining Gemini and shared Claude/GPT allowances +for Google sign-ins. Each group has separate five-hour and weekly limits and reset +times. Limits refresh with provider status and include usage outside T3 Code. + A server restart keeps your Google sign-in. The provider shows the saved account until a session, a refresh, or a sign-out reports something new. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 668103302417..3b74ffa3aa12 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1395,3 +1395,61 @@ describe("ChatGPT sharing presentation", () => { ).toBe(false); }); }); + +describe("Antigravity limit presentation", () => { + it("keeps the shared Claude/GPT bucket separate from Gemini and pairs their windows", () => { + const antigravity = provider({ + instanceId: ProviderInstanceId.make("antigravity"), + driver: ProviderDriverKind.make("antigravity"), + usageLimits: { + checkedAt: "2026-10-09T12:00:00.000Z", + windows: [ + { ...window, id: "3p-5h", label: "Claude & GPT (shared) · 5-hour", usedPercent: 90 }, + { ...window, id: "gemini-5h", label: "Gemini · 5-hour", usedPercent: 20 }, + { + ...window, + id: "3p-weekly", + kind: "weekly", + label: "Claude & GPT (shared) · Weekly", + usedPercent: 100, + }, + { + ...window, + id: "gemini-weekly", + kind: "weekly", + label: "Gemini · Weekly", + usedPercent: 10, + }, + ], + }, + }); + const accounts = collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("a"), + { + entry: { target: { label: "Local" } }, + serverConfig: { + providers: [ + provider({ + usageLimits: { checkedAt: "2026-10-09T12:00:00.000Z", windows: [window] }, + }), + antigravity, + ], + }, + }, + ], + ]), + ); + const pools = collectLimitPools(accounts, now); + expect(pools.map((pool) => pool.driver)).toEqual(["codex", "antigravity"]); + const windows = displayLimitWindows(pools[1]!); + expect(windows.map((window) => window.id)).toEqual([ + "gemini-5h", + "gemini-weekly", + "3p-5h", + "3p-weekly", + ]); + expect(windows.map((window) => window.remainingPercent)).toEqual([80, 90, 10, 0]); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 85c5e6e39f88..4a2f07f1df6c 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -397,6 +397,17 @@ export interface LimitPool { /** Show Cursor's two usable pools instead of a combined percentage when both are available. */ export function displayLimitWindows(pool: LimitPool) { + if (pool.driver === "antigravity") { + // Keep each model group's five-hour and weekly buckets next to one another. + return [...pool.windows].sort( + (left, right) => + Number(!left.id.startsWith("gemini-")) - Number(!right.id.startsWith("gemini-")) || + left.id + .replace(/-(5h|weekly|quota)$/, "") + .localeCompare(right.id.replace(/-(5h|weekly|quota)$/, "")) || + WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], + ); + } if (pool.driver !== "cursor") return pool.windows; const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed");