| 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,7 +27,11 @@ import { environmentPresentations } from "../../state/presentation"; |
| 27 | import { ResetCredits } from "./UsageLimitsSection"; | 27 | import { ResetCredits } from "./UsageLimitsSection"; |
| 28 | import { useProviderColors } from "./usageProviders"; | 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 | const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; | 35 | const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; |
| 32 | 36 | ||
| 33 | function accountName(account: LimitAccount) { | 37 | function accountName(account: LimitAccount) { |
| ... | @@ -264,7 +268,13 @@ export function UsageLimitsSection({ | ... | @@ -264,7 +268,13 @@ export function UsageLimitsSection({ |
| 264 | <PoolWindowCard | 268 | <PoolWindowCard |
| 265 | key={`${window.kind}:${window.id}`} | 269 | key={`${window.kind}:${window.id}`} |
| 266 | pool={window} | 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 | now={now} | 278 | now={now} |
| 269 | environmentIds={ | 279 | environmentIds={ |
| 270 | selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds] | 280 | selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds] |
apps/server/src/provider/AntigravityProvider.test.ts+76-1| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | ProviderDriverKind, | 6 | ProviderDriverKind, |
| 7 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 8 | ProviderSetupError, | 8 | ProviderSetupError, |
| 9 | type ServerProviderUsageLimits, | ||
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | import * as Deferred from "effect/Deferred"; | 11 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| ... | @@ -128,7 +129,11 @@ const layerTest = ProviderHostLive.layer.pipe( | ... | @@ -128,7 +129,11 @@ const layerTest = ProviderHostLive.layer.pipe( |
| 128 | type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; | 129 | type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; |
| 129 | 130 | ||
| 130 | const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( | 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 | const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>(); | 138 | const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>(); |
| 134 | const probeCalls = yield* Ref.make(0); | 139 | const probeCalls = yield* Ref.make(0); |
| ... | @@ -141,6 +146,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( | ... | @@ -141,6 +146,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( |
| 141 | decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), | 146 | decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), |
| 142 | { | 147 | { |
| 143 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), | 148 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), |
| 149 | ...(options.probeUsage ? { probeUsage: options.probeUsage } : {}), | ||
| 144 | probe: Ref.update(probeCalls, (count) => count + 1).pipe( | 150 | probe: Ref.update(probeCalls, (count) => count + 1).pipe( |
| 145 | Effect.andThen(Ref.get(probe)), | 151 | Effect.andThen(Ref.get(probe)), |
| 146 | Effect.flatten, | 152 | Effect.flatten, |
| ... | @@ -670,4 +676,73 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { | ... | @@ -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 +6,7 @@ import { |
| 6 | type ServerProvider, | 6 | type ServerProvider, |
| 7 | type ServerProviderModel, | 7 | type ServerProviderModel, |
| 8 | type ServerProviderSlashCommand, | 8 | type ServerProviderSlashCommand, |
| 9 | type ServerProviderUsageLimits, | ||
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | 11 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 11 | import * as DateTime from "effect/DateTime"; | 12 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -29,6 +30,7 @@ import { | ... | @@ -29,6 +30,7 @@ import { |
| 29 | isCommandMissingCause, | 30 | isCommandMissingCause, |
| 30 | type ServerProviderDraft, | 31 | type ServerProviderDraft, |
| 31 | } from "@t3tools/provider-core/server/snapshotProbe"; | 32 | } from "@t3tools/provider-core/server/snapshotProbe"; |
| 33 | import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits"; | ||
| 32 | 34 | ||
| 33 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); | 35 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); |
| 34 | const MAX_WORKSPACE_SNAPSHOTS = 32; | 36 | const MAX_WORKSPACE_SNAPSHOTS = 32; |
| ... | @@ -123,6 +125,8 @@ interface AntigravityProviderOptions { | ... | @@ -123,6 +125,8 @@ interface AntigravityProviderOptions { |
| 123 | EffectAcpErrors.AcpError | ProviderSetupError | 125 | EffectAcpErrors.AcpError | ProviderSetupError |
| 124 | >; | 126 | >; |
| 125 | readonly supportsTextGeneration: Effect.Effect<boolean>; | 127 | readonly supportsTextGeneration: Effect.Effect<boolean>; |
| 128 | readonly probeUsage?: Effect.Effect<ServerProviderUsageLimits>; | ||
| 129 | readonly clearUsage?: Effect.Effect<void>; | ||
| 126 | readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; | 130 | readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; |
| 127 | /** Auth type and label published once a session authenticates. */ | 131 | /** Auth type and label published once a session authenticates. */ |
| 128 | readonly auth?: { readonly type: string; readonly label: string }; | 132 | readonly auth?: { readonly type: string; readonly label: string }; |
| ... | @@ -192,6 +196,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -192,6 +196,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 192 | : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; | 196 | : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; |
| 193 | const supportsTextGeneration = | 197 | const supportsTextGeneration = |
| 194 | initialized !== undefined ? yield* options.supportsTextGeneration : false; | 198 | initialized !== undefined ? yield* options.supportsTextGeneration : false; |
| 199 | const usageLimits = | ||
| 200 | initialized !== undefined && options.probeUsage ? yield* options.probeUsage : undefined; | ||
| 195 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | 201 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 196 | const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { | 202 | const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { |
| 197 | if (state.authRevision !== before.authRevision) return state; | 203 | if (state.authRevision !== before.authRevision) return state; |
| ... | @@ -212,6 +218,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -212,6 +218,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 212 | version: initialized?.agentInfo?.version || draft.version, | 218 | version: initialized?.agentInfo?.version || draft.version, |
| 213 | status: errorMessage ? "error" : authenticated ? "ready" : "warning", | 219 | status: errorMessage ? "error" : authenticated ? "ready" : "warning", |
| 214 | checkedAt: updatedAt, | 220 | checkedAt: updatedAt, |
| 221 | usageLimits: missingInstallation | ||
| 222 | ? undefined | ||
| 223 | : resolveUsageLimitsAfterProbe({ | ||
| 224 | published: draft.usageLimits, | ||
| 225 | probed: usageLimits ?? draft.usageLimits, | ||
| 226 | }), | ||
| 215 | ...(missingInstallation | 227 | ...(missingInstallation |
| 216 | ? { | 228 | ? { |
| 217 | models: [], | 229 | models: [], |
| ... | @@ -261,6 +273,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -261,6 +273,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 261 | ) { | 273 | ) { |
| 262 | const before = yield* SubscriptionRef.get(metadata); | 274 | const before = yield* SubscriptionRef.get(metadata); |
| 263 | const supportsTextGeneration = yield* options.supportsTextGeneration; | 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 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | 280 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 265 | yield* SubscriptionRef.update(metadata, (state) => { | 281 | yield* SubscriptionRef.update(metadata, (state) => { |
| 266 | if ( | 282 | if ( |
| ... | @@ -285,6 +301,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -285,6 +301,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 285 | label: options.auth?.label ?? "Google account", | 301 | label: options.auth?.label ?? "Google account", |
| 286 | }, | 302 | }, |
| 287 | checkedAt: updatedAt, | 303 | checkedAt: updatedAt, |
| 304 | usageLimits: resolveUsageLimitsAfterProbe({ | ||
| 305 | published: draft.usageLimits, | ||
| 306 | probed: usageLimits ?? draft.usageLimits, | ||
| 307 | }), | ||
| 288 | models: buildAntigravityModelsFromSession(started.sessionSetupResult), | 308 | models: buildAntigravityModelsFromSession(started.sessionSetupResult), |
| 289 | supportsTextGeneration, | 309 | supportsTextGeneration, |
| 290 | ...(cwd | 310 | ...(cwd |
| ... | @@ -350,6 +370,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -350,6 +370,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 350 | }); | 370 | }); |
| 351 | 371 | ||
| 352 | const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { | 372 | const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { |
| 373 | if (options.clearUsage) yield* options.clearUsage; | ||
| 353 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | 374 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 354 | yield* SubscriptionRef.update( | 375 | yield* SubscriptionRef.update( |
| 355 | metadata, | 376 | metadata, |
| ... | @@ -367,6 +388,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -367,6 +388,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 367 | skills: [], | 388 | skills: [], |
| 368 | workspaceSnapshots: [], | 389 | workspaceSnapshots: [], |
| 369 | supportsTextGeneration: false, | 390 | supportsTextGeneration: false, |
| 391 | usageLimits: undefined, | ||
| 370 | }, | 392 | }, |
| 371 | }) satisfies AntigravityProviderState, | 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,6 +15,7 @@ import * as Path from "effect/Path"; |
| 15 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 16 | import * as TestClock from "effect/testing/TestClock"; | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 17 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | 17 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 18 | import { HttpClient } from "effect/http"; | ||
| 18 | 19 | ||
| 19 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 20 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 20 | import * as ServerConfig from "../../config.ts"; | 21 | import * as ServerConfig from "../../config.ts"; |
| ... | @@ -246,6 +247,12 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { | ... | @@ -246,6 +247,12 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 246 | prefix: "t3-antigravity-driver-config-", | 247 | prefix: "t3-antigravity-driver-config-", |
| 247 | }).pipe( | 248 | }).pipe( |
| 248 | Layer.provideMerge(NodeServices.layer), | 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 | Layer.provideMerge(ServerSettings.layerTest()), | 256 | Layer.provideMerge(ServerSettings.layerTest()), |
| 250 | Layer.provideMerge( | 257 | Layer.provideMerge( |
| 251 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | 258 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ |
apps/server/src/provider/Drivers/AntigravityDriver.ts+15| ... | @@ -17,6 +17,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -17,6 +17,7 @@ import * as Schema from "effect/Schema"; |
| 17 | import * as Scope from "effect/Scope"; | 17 | import * as Scope from "effect/Scope"; |
| 18 | import * as Stream from "effect/Stream"; | 18 | import * as Stream from "effect/Stream"; |
| 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 20 | import { HttpClient } from "effect/http"; | ||
| 20 | import type { AcpError } from "effect-acp/errors"; | 21 | import type { AcpError } from "effect-acp/errors"; |
| 21 | 22 | ||
| 22 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | 23 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| ... | @@ -53,6 +54,7 @@ import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/Antigr | ... | @@ -53,6 +54,7 @@ import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/Antigr |
| 53 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; | 54 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; |
| 54 | import { ProviderDriverError } from "../Errors.ts"; | 55 | import { ProviderDriverError } from "../Errors.ts"; |
| 55 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; | 56 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; |
| 57 | import { makeAntigravityUsageProbe } from "../antigravityUsageLimits.ts"; | ||
| 56 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | 58 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 57 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; | 59 | import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; |
| 58 | import { | 60 | import { |
| ... | @@ -74,6 +76,7 @@ export type AntigravityDriverEnv = | ... | @@ -74,6 +76,7 @@ export type AntigravityDriverEnv = |
| 74 | | ChildProcessSpawner.ChildProcessSpawner | 76 | | ChildProcessSpawner.ChildProcessSpawner |
| 75 | | Crypto.Crypto | 77 | | Crypto.Crypto |
| 76 | | FileSystem.FileSystem | 78 | | FileSystem.FileSystem |
| 79 | | HttpClient.HttpClient | ||
| 77 | | IdAllocator.IdAllocatorV2 | 80 | | IdAllocator.IdAllocatorV2 |
| 78 | | McpProviderSessions.McpProviderSessions | 81 | | McpProviderSessions.McpProviderSessions |
| 79 | | ModelCatalog.ModelCatalog | 82 | | ModelCatalog.ModelCatalog |
| ... | @@ -95,6 +98,7 @@ export const AntigravityDriver: ProviderDriver< | ... | @@ -95,6 +98,7 @@ export const AntigravityDriver: ProviderDriver< |
| 95 | Effect.gen(function* () { | 98 | Effect.gen(function* () { |
| 96 | const crypto = yield* Crypto.Crypto; | 99 | const crypto = yield* Crypto.Crypto; |
| 97 | const fileSystem = yield* FileSystem.FileSystem; | 100 | const fileSystem = yield* FileSystem.FileSystem; |
| 101 | const httpClient = yield* HttpClient.HttpClient; | ||
| 98 | const path = yield* Path.Path; | 102 | const path = yield* Path.Path; |
| 99 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 103 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 100 | const host = yield* ProviderHost.ProviderHost; | 104 | const host = yield* ProviderHost.ProviderHost; |
| ... | @@ -384,9 +388,20 @@ export const AntigravityDriver: ProviderDriver< | ... | @@ -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 | const provider = yield* makeAntigravityProvider(settings, { | 400 | const provider = yield* makeAntigravityProvider(settings, { |
| 388 | stampIdentity: classifyModels, | 401 | stampIdentity: classifyModels, |
| 389 | probe, | 402 | probe, |
| 403 | probeUsage: usage.probe, | ||
| 404 | clearUsage: usage.clear, | ||
| 390 | auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, | 405 | auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, |
| 391 | supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( | 406 | supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( |
| 392 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 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,7 +49,13 @@ const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof Ga |
| 49 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ | 49 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ |
| 50 | export function barColor(driver: ServerProvider["driver"]): string { | 50 | export function barColor(driver: ServerProvider["driver"]): string { |
| 51 | const kind: UsageProviderKind | undefined = | 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 | return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; | 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,6 +125,10 @@ is refused while the runtime is in use. |
| 125 | 125 | ||
| 126 | ## Check access and troubleshoot | 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 | A server restart keeps your Google sign-in. The provider shows the saved account | 132 | A server restart keeps your Google sign-in. The provider shows the saved account |
| 129 | until a session, a refresh, or a sign-out reports something new. | 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,3 +1395,61 @@ describe("ChatGPT sharing presentation", () => { |
| 1395 | ).toBe(false); | 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,6 +397,17 @@ export interface LimitPool { |
| 397 | 397 | ||
| 398 | /** Show Cursor's two usable pools instead of a combined percentage when both are available. */ | 398 | /** Show Cursor's two usable pools instead of a combined percentage when both are available. */ |
| 399 | export function displayLimitWindows(pool: LimitPool) { | 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 | if (pool.driver !== "cursor") return pool.windows; | 411 | if (pool.driver !== "cursor") return pool.windows; |
| 401 | const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); | 412 | const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); |
| 402 | const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed"); | 413 | const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed"); |