| author | |
| committer | |
| log | 716357cbbaca914ee1cbaef7b9fe3ea79319d1b6 |
| tree | 4c7604fdd2b07449a839910eb32ec3facf02df96 |
| parent | 29e09df979955a22e2ad35bd58a13518861b9551 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 27 | 27 | import { ResetCredits } from "./UsageLimitsSection"; |
| 28 | 28 | import { useProviderColors } from "./usageProviders"; |
| 29 | 29 | |
| 30 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | |
| 30 | const DRIVER_LABEL: Partial<Record<string, string>> = { | |
| 31 | codex: "Codex", | |
| 32 | claudeAgent: "Claude", | |
| 33 | antigravity: "Antigravity", | |
| 34 | }; | |
| 31 | 35 | const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; |
| 32 | 36 | |
| 33 | 37 | function accountName(account: LimitAccount) { |
| ... | ... | @@ -264,7 +268,13 @@ export function UsageLimitsSection({ |
| 264 | 268 | <PoolWindowCard |
| 265 | 269 | key={`${window.kind}:${window.id}`} |
| 266 | 270 | 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 | } | |
| 268 | 278 | now={now} |
| 269 | 279 | environmentIds={ |
| 270 | 280 | selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds] |
apps/server/src/provider/AntigravityProvider.test.ts+76-1| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProviderDriverKind, |
| 7 | 7 | ProviderInstanceId, |
| 8 | 8 | ProviderSetupError, |
| 9 | type ServerProviderUsageLimits, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import * as Deferred from "effect/Deferred"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -128,7 +129,11 @@ const layerTest = ProviderHostLive.layer.pipe( |
| 128 | 129 | type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; |
| 129 | 130 | |
| 130 | 131 | const 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 | } = {}, | |
| 132 | 137 | ) { |
| 133 | 138 | const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>(); |
| 134 | 139 | const probeCalls = yield* Ref.make(0); |
| ... | ... | @@ -141,6 +146,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( |
| 141 | 146 | decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), |
| 142 | 147 | { |
| 143 | 148 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), |
| 149 | ...(options.probeUsage ? { probeUsage: options.probeUsage } : {}), | |
| 144 | 150 | probe: Ref.update(probeCalls, (count) => count + 1).pipe( |
| 145 | 151 | Effect.andThen(Ref.get(probe)), |
| 146 | 152 | Effect.flatten, |
| ... | ... | @@ -670,4 +676,73 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { |
| 670 | 676 | }), |
| 671 | 677 | ), |
| 672 | 678 | ); |
| 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 | ); | |
| 673 | 748 | }); |
apps/server/src/provider/AntigravityProvider.ts+22| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | type ServerProvider, |
| 7 | 7 | type ServerProviderModel, |
| 8 | 8 | type ServerProviderSlashCommand, |
| 9 | type ServerProviderUsageLimits, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 11 | 12 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -29,6 +30,7 @@ import { |
| 29 | 30 | isCommandMissingCause, |
| 30 | 31 | type ServerProviderDraft, |
| 31 | 32 | } from "@t3tools/provider-core/server/snapshotProbe"; |
| 33 | import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits"; | |
| 32 | 34 | |
| 33 | 35 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); |
| 34 | 36 | const MAX_WORKSPACE_SNAPSHOTS = 32; |
| ... | ... | @@ -123,6 +125,8 @@ interface AntigravityProviderOptions { |
| 123 | 125 | EffectAcpErrors.AcpError | ProviderSetupError |
| 124 | 126 | >; |
| 125 | 127 | readonly supportsTextGeneration: Effect.Effect<boolean>; |
| 128 | readonly probeUsage?: Effect.Effect<ServerProviderUsageLimits>; | |
| 129 | readonly clearUsage?: Effect.Effect<void>; | |
| 126 | 130 | readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; |
| 127 | 131 | /** Auth type and label published once a session authenticates. */ |
| 128 | 132 | readonly auth?: { readonly type: string; readonly label: string }; |
| ... | ... | @@ -192,6 +196,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 192 | 196 | : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; |
| 193 | 197 | const supportsTextGeneration = |
| 194 | 198 | initialized !== undefined ? yield* options.supportsTextGeneration : false; |
| 199 | const usageLimits = | |
| 200 | initialized !== undefined && options.probeUsage ? yield* options.probeUsage : undefined; | |
| 195 | 201 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 196 | 202 | const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { |
| 197 | 203 | if (state.authRevision !== before.authRevision) return state; |
| ... | ... | @@ -212,6 +218,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 212 | 218 | version: initialized?.agentInfo?.version || draft.version, |
| 213 | 219 | status: errorMessage ? "error" : authenticated ? "ready" : "warning", |
| 214 | 220 | checkedAt: updatedAt, |
| 221 | usageLimits: missingInstallation | |
| 222 | ? undefined | |
| 223 | : resolveUsageLimitsAfterProbe({ | |
| 224 | published: draft.usageLimits, | |
| 225 | probed: usageLimits ?? draft.usageLimits, | |
| 226 | }), | |
| 215 | 227 | ...(missingInstallation |
| 216 | 228 | ? { |
| 217 | 229 | models: [], |
| ... | ... | @@ -261,6 +273,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 261 | 273 | ) { |
| 262 | 274 | const before = yield* SubscriptionRef.get(metadata); |
| 263 | 275 | 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; | |
| 264 | 280 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 265 | 281 | yield* SubscriptionRef.update(metadata, (state) => { |
| 266 | 282 | if ( |
| ... | ... | @@ -285,6 +301,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 285 | 301 | label: options.auth?.label ?? "Google account", |
| 286 | 302 | }, |
| 287 | 303 | checkedAt: updatedAt, |
| 304 | usageLimits: resolveUsageLimitsAfterProbe({ | |
| 305 | published: draft.usageLimits, | |
| 306 | probed: usageLimits ?? draft.usageLimits, | |
| 307 | }), | |
| 288 | 308 | models: buildAntigravityModelsFromSession(started.sessionSetupResult), |
| 289 | 309 | supportsTextGeneration, |
| 290 | 310 | ...(cwd |
| ... | ... | @@ -350,6 +370,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 350 | 370 | }); |
| 351 | 371 | |
| 352 | 372 | const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { |
| 373 | if (options.clearUsage) yield* options.clearUsage; | |
| 353 | 374 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 354 | 375 | yield* SubscriptionRef.update( |
| 355 | 376 | metadata, |
| ... | ... | @@ -367,6 +388,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 367 | 388 | skills: [], |
| 368 | 389 | workspaceSnapshots: [], |
| 369 | 390 | supportsTextGeneration: false, |
| 391 | usageLimits: undefined, | |
| 370 | 392 | }, |
| 371 | 393 | }) satisfies AntigravityProviderState, |
| 372 | 394 | ); |
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+7| ... | ... | @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; |
| 15 | 15 | import * as Schema from "effect/Schema"; |
| 16 | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 17 | 17 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 18 | import { HttpClient } from "effect/http"; | |
| 18 | 19 | |
| 19 | 20 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 20 | 21 | import * as ServerConfig from "../../config.ts"; |
| ... | ... | @@ -246,6 +247,12 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 246 | 247 | prefix: "t3-antigravity-driver-config-", |
| 247 | 248 | }).pipe( |
| 248 | 249 | Layer.provideMerge(NodeServices.layer), |
| 250 | Layer.provideMerge( | |
| 251 | Layer.succeed( | |
| 252 | HttpClient.HttpClient, | |
| 253 | HttpClient.make(() => Effect.die("Unexpected quota request")), | |
| 254 | ), | |
| 255 | ), | |
| 249 | 256 | Layer.provideMerge(ServerSettings.layerTest()), |
| 250 | 257 | Layer.provideMerge( |
| 251 | 258 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ |
apps/server/src/provider/Drivers/AntigravityDriver.ts+15| ... | ... | @@ -17,6 +17,7 @@ import * as Schema from "effect/Schema"; |
| 17 | 17 | import * as Scope from "effect/Scope"; |
| 18 | 18 | import * as Stream from "effect/Stream"; |
| 19 | 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 20 | import { HttpClient } from "effect/http"; | |
| 20 | 21 | import type { AcpError } from "effect-acp/errors"; |
| 21 | 22 | |
| 22 | 23 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| ... | ... | @@ -53,6 +54,7 @@ import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/Antigr |
| 53 | 54 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; |
| 54 | 55 | import { ProviderDriverError } from "../Errors.ts"; |
| 55 | 56 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; |
| 57 | import { makeAntigravityUsageProbe } from "../antigravityUsageLimits.ts"; | |
| 56 | 58 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 57 | 59 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; |
| 58 | 60 | import { |
| ... | ... | @@ -74,6 +76,7 @@ export type AntigravityDriverEnv = |
| 74 | 76 | | ChildProcessSpawner.ChildProcessSpawner |
| 75 | 77 | | Crypto.Crypto |
| 76 | 78 | | FileSystem.FileSystem |
| 79 | | HttpClient.HttpClient | |
| 77 | 80 | | IdAllocator.IdAllocatorV2 |
| 78 | 81 | | McpProviderSessions.McpProviderSessions |
| 79 | 82 | | ModelCatalog.ModelCatalog |
| ... | ... | @@ -95,6 +98,7 @@ export const AntigravityDriver: ProviderDriver< |
| 95 | 98 | Effect.gen(function* () { |
| 96 | 99 | const crypto = yield* Crypto.Crypto; |
| 97 | 100 | const fileSystem = yield* FileSystem.FileSystem; |
| 101 | const httpClient = yield* HttpClient.HttpClient; | |
| 98 | 102 | const path = yield* Path.Path; |
| 99 | 103 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 100 | 104 | const host = yield* ProviderHost.ProviderHost; |
| ... | ... | @@ -384,9 +388,20 @@ export const AntigravityDriver: ProviderDriver< |
| 384 | 388 | }; |
| 385 | 389 | }); |
| 386 | 390 | |
| 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 | ); | |
| 387 | 400 | const provider = yield* makeAntigravityProvider(settings, { |
| 388 | 401 | stampIdentity: classifyModels, |
| 389 | 402 | probe, |
| 403 | probeUsage: usage.probe, | |
| 404 | clearUsage: usage.clear, | |
| 390 | 405 | auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, |
| 391 | 406 | supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( |
| 392 | 407 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
apps/server/src/provider/antigravityUsageLimits.test.ts created+244| ... | ... | @@ -0,0 +1,244 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; | |
| 8 | ||
| 9 | import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts"; | |
| 10 | ||
| 11 | const 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 | ||
| 50 | const 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 | ||
| 111 | describe("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 @@ |
| 1 | import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; | |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 3 | import * as Clock from "effect/Clock"; | |
| 4 | import * as Crypto from "effect/Crypto"; | |
| 5 | import * as DateTime from "effect/DateTime"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 12 | ||
| 13 | import { | |
| 14 | makeUnavailableUsageLimits, | |
| 15 | makeUsageLimits, | |
| 16 | } from "@t3tools/provider-core/server/usageLimits"; | |
| 17 | ||
| 18 | const ENDPOINTS = [ | |
| 19 | "https://daily-cloudcode-pa.googleapis.com", | |
| 20 | "https://cloudcode-pa.googleapis.com", | |
| 21 | ]; | |
| 22 | const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; | |
| 23 | const TokenFile = Schema.Struct({ | |
| 24 | client_id: Schema.NonEmptyString, | |
| 25 | client_secret: Schema.NonEmptyString, | |
| 26 | refresh_token: Schema.NonEmptyString, | |
| 27 | project_id: Schema.optional(Schema.String), | |
| 28 | }); | |
| 29 | const TokenResponse = Schema.Struct({ | |
| 30 | access_token: Schema.NonEmptyString, | |
| 31 | expires_in: Schema.Number.check(Schema.isGreaterThan(0)), | |
| 32 | }); | |
| 33 | const Quota = Schema.Struct({ | |
| 34 | remainingFraction: Schema.optional(Schema.Number), | |
| 35 | resetTime: Schema.optional(Schema.String), | |
| 36 | }); | |
| 37 | const 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 | }); | |
| 44 | const 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 | }); | |
| 55 | const 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 | ||
| 66 | const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile)); | |
| 67 | const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); | |
| 68 | const decodeSummary = Schema.decodeUnknownEffect(Summary); | |
| 69 | const decodeModels = Schema.decodeUnknownEffect(Models); | |
| 70 | ||
| 71 | function 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 | ||
| 76 | function 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. */ | |
| 98 | function 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. */ | |
| 127 | function 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. */ | |
| 160 | export 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 |
| 49 | 49 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ |
| 50 | 50 | export function barColor(driver: ServerProvider["driver"]): string { |
| 51 | 51 | 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; | |
| 53 | 59 | return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; |
| 54 | 60 | } |
| 55 | 61 |
docs/user/providers-antigravity.md+4| ... | ... | @@ -125,6 +125,10 @@ is refused while the runtime is in use. |
| 125 | 125 | |
| 126 | 126 | ## Check access and troubleshoot |
| 127 | 127 | |
| 128 | Open **Usage > Limits** to see the remaining Gemini and shared Claude/GPT allowances | |
| 129 | for Google sign-ins. Each group has separate five-hour and weekly limits and reset | |
| 130 | times. Limits refresh with provider status and include usage outside T3 Code. | |
| 131 | ||
| 128 | 132 | A server restart keeps your Google sign-in. The provider shows the saved account |
| 129 | 133 | until a session, a refresh, or a sign-out reports something new. |
| 130 | 134 |
packages/shared/src/usageLimits.test.ts+58| ... | ... | @@ -1395,3 +1395,61 @@ describe("ChatGPT sharing presentation", () => { |
| 1395 | 1395 | ).toBe(false); |
| 1396 | 1396 | }); |
| 1397 | 1397 | }); |
| 1398 | ||
| 1399 | describe("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 { |
| 397 | 397 | |
| 398 | 398 | /** Show Cursor's two usable pools instead of a combined percentage when both are available. */ |
| 399 | 399 | export 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 | } | |
| 400 | 411 | if (pool.driver !== "cursor") return pool.windows; |
| 401 | 412 | const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); |
| 402 | 413 | const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed"); |