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