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
111 changes: 110 additions & 1 deletion packages/agents-usage/src/collectors/antigravity.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,117 @@
import { describe, expect, it } from "vitest";
import { antigravityPool, antigravityPoolWindows } from "./antigravity";
import {
antigravityPool,
antigravityPoolWindows,
antigravityQuotaSummaryWindows,
} from "./antigravity";

const NOW = 1_717_000_000_000;

/** Trimmed shape of a real RetrieveUserQuotaSummary response. */
const QUOTA_SUMMARY = {
response: {
groups: [
{
displayName: "Gemini Models",
description: "Models within this group: Gemini Flash, Gemini Pro",
buckets: [
{
bucketId: "gemini-weekly",
displayName: "Weekly Limit",
window: "weekly",
remainingFraction: 0.88907504,
resetTime: "2026-06-27T03:47:11Z",
},
{
bucketId: "gemini-5h",
displayName: "Five Hour Limit",
window: "5h",
remainingFraction: 0.3994549,
resetTime: "2026-06-25T03:51:42Z",
},
],
},
{
displayName: "Claude and GPT models",
description: "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS",
buckets: [
{
bucketId: "3p-weekly",
displayName: "Weekly Limit",
window: "weekly",
remainingFraction: 1,
},
{
bucketId: "3p-5h",
displayName: "Five Hour Limit",
window: "5h",
remainingFraction: 1,
resetTime: "2026-06-25T08:34:03Z",
},
],
},
],
},
};

describe("antigravityQuotaSummaryWindows", () => {
it("builds four group×cadence windows ordered Gemini-first, 5h-before-weekly", () => {
const windows = antigravityQuotaSummaryWindows(QUOTA_SUMMARY);
expect(windows.map((w) => w.id)).toEqual([
"antigravity:gemini:session-5h",
"antigravity:gemini:weekly",
"antigravity:claude:session-5h",
"antigravity:claude:weekly",
]);
expect(windows.map((w) => w.label)).toEqual([
"Gemini · 5h",
"Gemini · Weekly",
"Claude · 5h",
"Claude · Weekly",
]);
});

it("converts the remaining fraction to used percent and parses reset times", () => {
const windows = antigravityQuotaSummaryWindows(QUOTA_SUMMARY);
const gemini5h = windows.find((w) => w.id === "antigravity:gemini:session-5h");
// remaining 0.3994549 -> ~60.1% used.
expect(gemini5h?.usedPercent).toBeCloseTo(60.1, 1);
expect(gemini5h?.resetsAt).toBe(Date.parse("2026-06-25T03:51:42Z"));

const geminiWeekly = windows.find((w) => w.id === "antigravity:gemini:weekly");
expect(geminiWeekly?.usedPercent).toBeCloseTo(11.1, 1);

// Untouched Claude group -> 0% used; the weekly bucket has no reset time.
const claudeWeekly = windows.find((w) => w.id === "antigravity:claude:weekly");
expect(claudeWeekly?.usedPercent).toBe(0);
expect(claudeWeekly?.resetsAt).toBeUndefined();
});

it("skips buckets without a numeric fraction or recognizable cadence", () => {
const windows = antigravityQuotaSummaryWindows({
response: {
groups: [
{
displayName: "Gemini Models",
buckets: [
{ window: "weekly" }, // no remainingFraction
{ window: "daily", remainingFraction: 0.5 }, // unknown cadence
{ window: "5h", remainingFraction: 0.5 },
],
},
],
},
});
expect(windows.map((w) => w.id)).toEqual(["antigravity:gemini:session-5h"]);
});

it("returns [] for a body with no recognizable groups", () => {
expect(antigravityQuotaSummaryWindows(undefined)).toEqual([]);
expect(antigravityQuotaSummaryWindows({ response: {} })).toEqual([]);
expect(antigravityQuotaSummaryWindows({ anything: [1, 2] })).toEqual([]);
});
});

describe("antigravityPool", () => {
it("splits Gemini Pro / Flash and folds everything else into Claude", () => {
expect(antigravityPool("Gemini 3.1 Pro (High)").id).toBe("gemini-pro");
Expand Down
133 changes: 127 additions & 6 deletions packages/agents-usage/src/collectors/antigravity.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,141 @@
import { toEpochMs, usedPercentFromRemaining } from "../formatters";
import type { UsageWindow } from "../types";

/**
* Antigravity quota pooling, shared by the supervisor's local language-server
* Antigravity quota parsing, shared by the supervisor's local language-server
* scanner (`antigravityUsageScanner.ts`).
*
* Antigravity's "Model usage" view (and steipete/codexbar + robinebers/openusage)
* folds every model into three broad quota pools — Gemini Pro, Gemini Flash, and
* Claude (all non-Gemini models, including GPT-OSS) — rather than one bar per
* model. Pool window ids are `antigravity:<pool>`.
* Antigravity now exposes a `RetrieveUserQuotaSummary` RPC that reports two model
* groups — "Gemini Models" and "Claude and GPT models" — each with a shared
* 5-hour limit and a weekly limit (this mirrors its in-app "Model Quota" view).
* {@link antigravityQuotaSummaryWindows} turns that into four windows whose ids
* are built by {@link antigravityWindowId} (e.g. `antigravity:gemini:session-5h`).
*
* The legacy path ({@link antigravityPoolWindows}) folds per-model
* `quotaInfo.remainingFraction` (which only ever carries the 5-hour limit) into
* three Gemini Pro / Gemini Flash / Claude pools, and stays as a fallback for
* Antigravity builds that predate the quota-summary RPC.
*
* Pure (no host dependency) so it stays unit-testable. Antigravity usage is
* collected supervisor-side from the local language server only; there is no
* always-on HTTP collector here (its Cloud Code surface reports a different
* backend's quota and was intentionally dropped to avoid inconsistent numbers).
*/

/** A model group key in the quota summary; drives window ids + ring grouping. */
export type AntigravityGroupKey = "gemini" | "claude";
/**
* A quota window cadence in the quota summary. We reuse the package's canonical
* `session-5h` token (shared with codex/factory) so `windowDurationMs` paces
* these windows without a special case.
*/
export type AntigravityCadence = "session-5h" | "weekly";

/**
* The window id for an Antigravity quota group + cadence. The single source of
* truth for the id format, shared by the collector and the renderer's ring
* descriptor so the two can't drift.
*/
export function antigravityWindowId(
group: AntigravityGroupKey,
cadence: AntigravityCadence,
): `antigravity:${AntigravityGroupKey}:${AntigravityCadence}` {
return `antigravity:${group}:${cadence}`;
}

/**
* Classify a quota-summary group by its display name. The Gemini group is named
* "Gemini Models"; everything else (currently "Claude and GPT models") folds
* into the `claude` group, matching the in-app split.
*/
function antigravityGroupKey(displayName: string): AntigravityGroupKey {
return /gemini/i.test(displayName) ? "gemini" : "claude";
}

const ANTIGRAVITY_GROUP_LABEL: Record<AntigravityGroupKey, string> = {
gemini: "Gemini",
claude: "Claude",
};

/**
* Resolve a bucket's cadence from its `window` discriminator (`"5h"` / `"weekly"`),
* falling back to its display name ("Five Hour Limit" / "Weekly Limit") in case
* the discriminator field is ever renamed. Returns undefined for an unrecognized
* cadence so the bucket is skipped rather than mislabeled.
*/
function antigravityCadence(bucket: Record<string, unknown>): AntigravityCadence | undefined {
const window = typeof bucket.window === "string" ? bucket.window.toLowerCase() : "";
if (window === "5h") return "session-5h";
if (window === "weekly") return "weekly";
const display = typeof bucket.displayName === "string" ? bucket.displayName.toLowerCase() : "";
if (display.includes("hour")) return "session-5h";
if (display.includes("week")) return "weekly";
return undefined;
}

const ANTIGRAVITY_CADENCE_LABEL: Record<AntigravityCadence, string> = {
"session-5h": "5h",
weekly: "Weekly",
};

/** Sort weight so windows render grouped (Gemini before Claude) and 5h before weekly. */
function antigravityWindowOrder(group: AntigravityGroupKey, cadence: AntigravityCadence): number {
return (group === "gemini" ? 0 : 1) * 2 + (cadence === "session-5h" ? 0 : 1);
}

/** Pull the `groups` array out of a RetrieveUserQuotaSummary body, tolerant of nesting. */
function quotaSummaryGroups(body: unknown): Record<string, unknown>[] {
if (!body || typeof body !== "object") return [];
const root = body as Record<string, unknown>;
const response =
root.response && typeof root.response === "object"
? (root.response as Record<string, unknown>)
: root;
const groups = response.groups;
if (!Array.isArray(groups)) return [];
return groups.filter((g): g is Record<string, unknown> => !!g && typeof g === "object");
}

/**
* Build the four usage windows from a `RetrieveUserQuotaSummary` response — one
* per (group × cadence). `remainingFraction` is 0-1 remaining, so usedPercent is
* its complement. Window ids come from {@link antigravityWindowId}; the trailing
* cadence segment lets the shared pacer infer the window length. Returns [] for a
* body without recognizable groups so the scanner can fall back to the legacy
* per-model pooling.
*/
export function antigravityQuotaSummaryWindows(body: unknown): UsageWindow[] {
const entries: { order: number; window: UsageWindow }[] = [];
const seen = new Set<string>();
for (const group of quotaSummaryGroups(body)) {
const displayName = typeof group.displayName === "string" ? group.displayName : "";
const groupKey = antigravityGroupKey(displayName);
const buckets = Array.isArray(group.buckets) ? group.buckets : [];
for (const raw of buckets) {
if (!raw || typeof raw !== "object") continue;
const bucket = raw as Record<string, unknown>;
const fraction = bucket.remainingFraction;
if (typeof fraction !== "number" || !Number.isFinite(fraction)) continue;
const cadence = antigravityCadence(bucket);
if (!cadence) continue;
const id = antigravityWindowId(groupKey, cadence);
if (seen.has(id)) continue;
seen.add(id);
const reset = toEpochMs(typeof bucket.resetTime === "string" ? bucket.resetTime : undefined);
entries.push({
order: antigravityWindowOrder(groupKey, cadence),
window: {
id,
label: `${ANTIGRAVITY_GROUP_LABEL[groupKey]} · ${ANTIGRAVITY_CADENCE_LABEL[cadence]}`,
usedPercent: usedPercentFromRemaining(fraction),
...(reset !== undefined ? { resetsAt: reset } : {}),
},
});
}
}
return entries.sort((a, b) => a.order - b.order).map((entry) => entry.window);
}

interface AntigravityPool {
id: "gemini-pro" | "gemini-flash" | "claude";
label: string;
Expand Down Expand Up @@ -78,7 +199,7 @@ export function antigravityPoolWindows(models: AntigravityModelQuota[]): UsageWi
.map((entry) => ({
id: `antigravity:${entry.pool.id}` as const,
label: entry.pool.label,
usedPercent: Math.round((1 - entry.remainingFraction) * 1000) / 10,
usedPercent: usedPercentFromRemaining(entry.remainingFraction),
unit: "requests" as const,
...(entry.resetsAt !== undefined ? { resetsAt: entry.resetsAt } : {}),
}));
Expand Down
2 changes: 2 additions & 0 deletions packages/agents-usage/src/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ describe("windowDurationMs", () => {
expect(windowDurationMs("codex:gpt-5:weekly", 0)).toBe(7 * DAY);
expect(windowDurationMs("factory:core:weekly", 0)).toBe(7 * DAY);
expect(windowDurationMs("gemini:gemini-2.5-pro", 0)).toBe(DAY);
expect(windowDurationMs("antigravity:gemini:session-5h", 0)).toBe(5 * HOUR);
expect(windowDurationMs("antigravity:claude:weekly", 0)).toBe(7 * DAY);
});

it("measures monthly windows back from the actual reset date, not a fixed 30d", () => {
Expand Down
17 changes: 14 additions & 3 deletions packages/agents-usage/src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export function normalizePercent(value: number | undefined): number | undefined
return Math.min(100, Math.max(0, Math.round(pct * 10) / 10));
}

/**
* Convert a 0-1 *remaining* fraction (as several quota APIs report, e.g.
* Antigravity / Gemini Code Assist) into a 0-100 *used* percent, clamped and
* rounded to 0.1%. The complement of "how much is left".
*/
export function usedPercentFromRemaining(remainingFraction: number): number {
const clamped = Math.min(1, Math.max(0, remainingFraction));
return Math.round((1 - clamped) * 1000) / 10;
}

/**
* Parse an HTTP `Retry-After` header into the epoch-ms instant a caller may
* retry. RFC 9110 allows two forms: `delta-seconds` (a non-negative integer
Expand Down Expand Up @@ -161,9 +171,10 @@ export function windowDurationMs(windowId: string, resetsAt: number): number | u
// Factory's legacy per-cycle "premium" pool is calendar-month aligned, not a
// rolling-cadence window.
if (windowId === "factory:premium") return calendarMonthBeforeMs(resetsAt);
// Namespaced rate-limit ids (codex:<limit>:<cadence>, factory:<pool>:<cadence>)
// carry their cadence as the trailing `:`-segment. Antigravity pools name a
// model family there (never a cadence word), so they fall through to undefined.
// Namespaced rate-limit ids (codex:<limit>:<cadence>, factory:<pool>:<cadence>,
// antigravity:<group>:<cadence>) carry their cadence as the trailing
// `:`-segment. The legacy Antigravity pool ids (`antigravity:gemini-pro`, ...)
// name a model family there instead, so they fall through to undefined.
switch (windowId.slice(windowId.lastIndexOf(":") + 1)) {
case "session-5h":
return 5 * HOUR_MS;
Expand Down
13 changes: 11 additions & 2 deletions packages/agents-usage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,14 @@ export {
ZAI_BIGMODEL_QUOTA_ENDPOINT,
} from "./collectors/zai";
export type { ZaiQuotaResponse } from "./collectors/zai";
export { antigravityPool, antigravityPoolWindows } from "./collectors/antigravity";
export type { AntigravityModelQuota } from "./collectors/antigravity";
export {
antigravityPool,
antigravityPoolWindows,
antigravityQuotaSummaryWindows,
antigravityWindowId,
} from "./collectors/antigravity";
export type {
AntigravityCadence,
AntigravityGroupKey,
AntigravityModelQuota,
} from "./collectors/antigravity";
2 changes: 2 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ describe("sharedSettingsFile", () => {
disabledProviders: [],
providerOrder: [],
collapsedProviders: [],
selectedRingGroups: {},
},
});

Expand Down Expand Up @@ -233,6 +234,7 @@ describe("sharedSettingsFile", () => {
disabledProviders: [],
providerOrder: [],
collapsedProviders: [],
selectedRingGroups: {},
},
});
expect(readFileSync(settingsPath, "utf8")).toContain('"themeMode": "dark"');
Expand Down
17 changes: 10 additions & 7 deletions src/renderer/components/providers/ProviderUsageCircle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { usageToneColor } from "./usageTone";
* rings — like a clock's hands, the faster session is the OUTER ring and the
* slower weekly/monthly is the INNER ring, so a full inner ring flags "weekly
* almost gone" even when the session is idle. Cursor renders Auto + Composer
* outside and API inside. Every other provider renders a SINGLE ring on its
* most-constrained window — an at-a-glance "closest to the limit" read. Which
* windows map to which ring is a per-provider descriptor in `usageProviders.ts`
* (see {@link pickUsageRings}). Each ring is colored by its own tone. Reuses the
* ring math from ThreadContextIndicator.
* outside and API inside. Antigravity shows one of its two quota groups (Gemini
* vs Claude+GPT), selected via `ringGroup`. Every other provider renders a
* SINGLE ring on its most-constrained window — an at-a-glance "closest to the
* limit" read. Which windows map to which ring is a per-provider descriptor in
* `usageProviders.ts` (see {@link pickUsageRings}). Each ring is colored by its
* own tone. Reuses the ring math from ThreadContextIndicator.
*/

function Ring(props: { window: UsageWindow; radius: number }) {
Expand Down Expand Up @@ -50,9 +51,11 @@ export function ProviderUsageCircle(props: {
kind: string;
windows: readonly UsageWindow[] | undefined;
size?: number;
/** Selected ring group for multi-group providers (e.g. Antigravity). */
ringGroup?: string | undefined;
}) {
const { kind, windows, size = 28 } = props;
const { outer, inner } = pickUsageRings(kind, windows);
const { kind, windows, size = 28, ringGroup } = props;
const { outer, inner } = pickUsageRings(kind, windows, ringGroup);
const outerRadius = 11;
const innerRadius = 7.5;

Expand Down
Loading