Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/server/src/provider/Drivers/AntigravityDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,6 +32,7 @@ import {
antigravityAuthConfigIssue,
antigravityAuthLabel,
antigravityAuthUsesBrowser,
antigravityTokenPath,
buildAntigravityAcpSpawnInput,
isAntigravitySignInRequiredError,
prepareAntigravityProfile,
Expand All @@ -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 {
Expand All @@ -74,6 +77,7 @@ export type AntigravityDriverEnv =
| ChildProcessSpawner.ChildProcessSpawner
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| IdAllocator.IdAllocatorV2
| ModelManifest.ModelManifest
| Path.Path
Expand All @@ -91,6 +95,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const fileSystem = yield* FileSystem.FileSystem;
const httpClient = yield* HttpClient.HttpClient;
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const serverConfig = yield* ServerConfig.ServerConfig;
Expand Down Expand Up @@ -390,6 +395,13 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
Effect.provideService(Path.Path, path),
Effect.orElseSucceed(() => 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) =>
Expand Down
56 changes: 36 additions & 20 deletions apps/server/src/provider/Layers/AntigravityProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -123,6 +125,7 @@ interface AntigravityProviderOptions {
EffectAcpErrors.AcpError | ProviderSetupError
>;
readonly supportsTextGeneration: Effect.Effect<boolean>;
readonly readUsageLimits?: Effect.Effect<ServerProviderUsageLimits | undefined>;
readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
/** Auth type and label published once a session authenticates. */
readonly auth?: { readonly type: string; readonly label: string };
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -227,6 +243,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
supportsTextGeneration && draft.auth.status !== "unauthenticated",
}
: {}),
...(usageLimits ? { usageLimits } : {}),
...(message ? { message } : {}),
},
} satisfies AntigravityProviderState;
Expand Down Expand Up @@ -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();
});

Expand Down
153 changes: 153 additions & 0 deletions apps/server/src/provider/Layers/antigravityUsageLimits.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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" });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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.",
}),
),
);
},
);
6 changes: 5 additions & 1 deletion apps/server/src/provider/antigravityAuthSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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,
};
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/provider/makeManagedServerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 8 additions & 2 deletions docs/internals/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Loading