Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

export interface AntigravityAccountIdentity {
readonly credentialFingerprint: string;
readonly credentialEmail?: string;
}

const decodeUserInfoEmail = Schema.decodeUnknownOption(Schema.Struct({ email: Schema.String }));

export function withAntigravityAccountEmail(
credentialFingerprint: string,
userInfo: unknown,
): AntigravityAccountIdentity {
const email = decodeUserInfoEmail(userInfo).pipe(
Option.map(({ email }) => email.trim()),
Option.filter((email) => email.length > 0),
Option.getOrUndefined,
);
return email ? { credentialFingerprint, credentialEmail: email } : { credentialFingerprint };
}
21 changes: 19 additions & 2 deletions apps/server/src/provider/antigravityUsageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
tokenStatus?: number;
refreshToken?: string;
userId?: string;
omitUserEmail?: boolean;
userInfoAt?: number;
missingToken?: boolean;
authMethod?: "oauth-personal" | "oauth-business" | "gemini-api-key";
Expand Down Expand Up @@ -107,7 +108,10 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
return HttpClientResponse.fromWeb(
request,
options.userId
? Response.json({ id: options.userId, email: "someone@example.com" })
? Response.json({
id: options.userId,
...(options.omitUserEmail ? {} : { email: "someone@example.com" }),
})
: Response.json({}, { status: 403 }),
);
}
Expand Down Expand Up @@ -588,7 +592,20 @@ describe("Antigravity subscription limits", () => {
expect(machineA.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(machineB.credentialFingerprint).toBe(machineA.credentialFingerprint);
expect(otherAccount.credentialFingerprint).not.toBe(machineA.credentialFingerprint);
expect(JSON.stringify(machineA)).not.toMatch(/google-user-1|someone@example.com/);
expect(machineA.credentialEmail).toBe("someone@example.com");
expect(JSON.stringify(machineA)).not.toMatch(/google-user-1/);
const unidentified = yield* fixture({ refreshToken: "refresh-a" }).pipe(
Effect.flatMap((usage) => usage.probe),
);
expect(unidentified.credentialFingerprint).not.toBe(machineA.credentialFingerprint);
expect(unidentified.credentialEmail).toBeUndefined();
const idOnly = yield* fixture({
refreshToken: "refresh-c",
userId: "google-user-1",
omitUserEmail: true,
}).pipe(Effect.flatMap((usage) => usage.probe));
expect(idOnly.credentialFingerprint).toBe(machineA.credentialFingerprint);
expect(idOnly.credentialEmail).toBeUndefined();
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);

Expand Down
25 changes: 20 additions & 5 deletions apps/server/src/provider/antigravityUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
readAntigravityCliUsageLogin,
type AntigravityRuntimeState,
} from "../fork/antigravityCliRuntime/antigravityCliRuntime.ts";
import {
type AntigravityAccountIdentity,
withAntigravityAccountEmail,
} from "../fork/antigravityAccountEmail/antigravityAccountEmail.ts";

export function antigravityUserAgent(platform: NodeJS.Platform, architecture: NodeJS.Architecture) {
return `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`;
Expand Down Expand Up @@ -227,7 +231,12 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
const platform = yield* HostProcess.Platform;
const architecture = yield* HostProcess.Architecture;
let cached:
| { fingerprint: string; account: string; accessToken: string; expiresAt: number }
| {
fingerprint: string;
account: AntigravityAccountIdentity;
accessToken: string;
expiresAt: number;
}
| undefined;
let generation = 0;

Expand Down Expand Up @@ -263,7 +272,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
);
const now = yield* Clock.currentTimeMillis;
let accessToken: string;
let account: string;
let account: AntigravityAccountIdentity;
if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
cached = undefined;
const refreshed = yield* request(
Expand All @@ -283,8 +292,14 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }),
),
).pipe(
Effect.flatMap(antigravityAccountFingerprint),
Effect.orElseSucceed(() => fingerprint),
Effect.flatMap((userInfo) =>
antigravityAccountFingerprint(userInfo).pipe(
Effect.map((credentialFingerprint) =>
withAntigravityAccountEmail(credentialFingerprint, userInfo),
),
),
),
Effect.orElseSucceed(() => ({ credentialFingerprint: fingerprint })),
);
if (generation === startedGeneration) {
cached = {
Expand Down Expand Up @@ -349,7 +364,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
message: "Antigravity did not return usable quota information.",
});
}
return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account };
return { ...makeUsageLimits({ checkedAt, windows }), ...account };
});
const probe = read.pipe(
Effect.provideService(Digest.Digest, crypto),
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/providerUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const ServerProviderUsageLimits = Schema.Struct({
/** A $0 monthly spend cap has no percentage, so it cannot be a window. */
zeroSpendCap: Schema.optional(Schema.Boolean),
credentialFingerprint: Schema.optional(TrimmedNonEmptyString),
/** The account e-mail read in the same identity answer as `credentialFingerprint`. */
credentialEmail: Schema.optional(TrimmedNonEmptyString),
resetCredits: Schema.optional(ServerProviderResetCredits),
externalUsage: Schema.optional(
Schema.Struct({
Expand Down
251 changes: 251 additions & 0 deletions packages/shared/src/fork/antigravityEmailBindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type UsageLimitSourceAccount,
UsageLimitSourceId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { collectLimitAccounts } from "../usageLimits.ts";

const antigravity = ProviderDriverKind.make("antigravity");
const checkedAt = "2026-09-03T11:00:00.000Z";
const window = {
id: "gemini-weekly",
kind: "weekly",
label: "Gemini · Weekly",
usedPercent: 20,
windowDurationMins: 7 * 24 * 60,
} as const;
const limits = (identity: { credentialFingerprint?: string; credentialEmail?: string }) => ({
checkedAt,
windows: [window],
...identity,
});

type Reader =
| {
readonly kind: "native";
readonly env: string;
readonly name: string;
readonly fp: string;
readonly email?: string;
}
| {
readonly kind: "hub";
readonly env: string;
readonly name: string;
readonly fp?: string;
readonly email?: string;
};

const paired = (env: string, name: string, fp: string, email: string): Reader => ({
kind: "native",
env,
name,
fp,
email,
});
const fingerprintOnly = (env: string, name: string, fp: string): Reader => ({
kind: "native",
env,
name,
fp,
});
const answeringHub = (env: string, name: string, fp: string): Reader => ({
kind: "hub",
env,
name,
fp,
});
const withholdingHub = (env: string, name: string, email: string): Reader => ({
kind: "hub",
env,
name,
email,
});

function accountsOf(readers: readonly Reader[]) {
const presentations = new Map<
EnvironmentId,
{
entry: { target: { label: string } };
serverConfig: {
providers: ServerProvider[];
usageLimitSources: Array<{
id: UsageLimitSourceId;
kind: "cliproxy";
label: string;
checkedAt: string;
accounts: UsageLimitSourceAccount[];
}>;
};
}
>();
for (const reader of readers) {
const environmentId = EnvironmentId.make(reader.env);
const presentation = presentations.get(environmentId) ?? {
entry: { target: { label: reader.env } },
serverConfig: {
providers: [],
usageLimitSources: [
{
id: UsageLimitSourceId.make("hub"),
kind: "cliproxy",
label: "hub",
checkedAt,
accounts: [],
},
],
},
};
presentations.set(environmentId, presentation);
if (reader.kind === "native") {
presentation.serverConfig.providers.push({
instanceId: ProviderInstanceId.make(reader.name),
driver: antigravity,
displayName: reader.name,
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt,
models: [],
slashCommands: [],
skills: [],
usageLimits: limits({
credentialFingerprint: reader.fp,
...(reader.email ? { credentialEmail: reader.email } : {}),
}),
});
} else {
presentation.serverConfig.usageLimitSources[0]!.accounts.push({
id: reader.name,
driver: antigravity,
...(reader.email ? { label: reader.name, email: reader.email } : {}),
usageLimits: limits(reader.fp ? { credentialFingerprint: reader.fp } : {}),
});
}
}
return collectLimitAccounts(presentations)
.map((account) => `${account.displayName}${account.email ? ` <${account.email}>` : ""}`)
.toSorted();
}

describe("Antigravity accounts identified by a Google fingerprint and e-mail pair", () => {
it.each([
{
name: "a current login and a hub that withholds userinfo",
readers: [
paired("env-a", "local", "google-x", "x@example.com"),
withholdingHub("env-a", "g1", "X@example.com"),
],
accounts: ["g1 <X@example.com>"],
},
{
name: "a current login, an older login, an answering hub and a withholding hub",
readers: [
paired("env-a", "local", "google-x", "x@example.com"),
fingerprintOnly("env-b", "remote", "google-x"),
answeringHub("env-b", "proxy", "google-x"),
withholdingHub("env-a", "g1", "x@example.com"),
],
accounts: ["g1 <x@example.com>"],
},
{
name: "two Google accounts, each read every way",
readers: [
paired("env-a", "local-x", "google-x", "x@example.com"),
paired("env-a", "local-y", "google-y", "y@example.com"),
fingerprintOnly("env-b", "remote-x", "google-x"),
fingerprintOnly("env-b", "remote-y", "google-y"),
withholdingHub("env-a", "gx", "x@example.com"),
withholdingHub("env-a", "gy", "y@example.com"),
],
accounts: ["gx <x@example.com>", "gy <y@example.com>"],
},
{
name: "one Google account whose e-mail changed between two machines",
readers: [
paired("env-a", "local", "google-x", "old@example.com"),
paired("env-b", "local", "google-x", "new@example.com"),
],
accounts: ["local"],
},
{
name: "two fingerprints claiming one e-mail keep apart, and so does the hub listing it",
readers: [
paired("env-a", "local-x", "google-x", "same@example.com"),
paired("env-b", "remote-y", "google-y", "same@example.com"),
withholdingHub("env-a", "g1", "same@example.com"),
],
accounts: ["g1 <same@example.com>", "local-x", "remote-y"],
},
{
name: "an older login and a withholding hub, with no reader that knows both names",
readers: [
fingerprintOnly("env-a", "local", "google-x"),
withholdingHub("env-a", "g1", "x@example.com"),
],
accounts: ["g1 <x@example.com>", "local"],
},
])("$name", ({ readers, accounts }) => {
expect(accountsOf(readers)).toEqual(accounts);
expect(accountsOf(readers.toReversed())).toEqual(accounts);
});

it("leaves other drivers keyed by e-mail first", () => {
const opencode = ProviderDriverKind.make("opencode");
const accounts = collectLimitAccounts(
new Map([
[
EnvironmentId.make("env-a"),
{
entry: { target: { label: "env-a" } },
serverConfig: {
providers: [
{
instanceId: ProviderInstanceId.make("opencode"),
driver: opencode,
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated", email: "same@example.com" },
checkedAt,
models: [],
slashCommands: [],
skills: [],
usageLimits: limits({
credentialFingerprint: "key-a",
credentialEmail: "same@example.com",
}),
},
],
usageLimitSources: [
{
id: UsageLimitSourceId.make("hub"),
kind: "cliproxy",
label: "hub",
checkedAt,
accounts: [
{
id: "go",
driver: opencode,
email: "same@example.com",
usageLimits: limits({}),
},
],
},
],
},
},
],
]),
);
expect(accounts).toHaveLength(1);
});
});
Loading
Loading