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,37 @@
# wp11 — #1082 per-account Gem/Cla quota for Google Antigravity (reimplementation; PR #2123 closed)

Issue #1082 (score 63). PR #2123 (chilung-cgu, +755/-40, 319 commits behind, hygiene/enforce-target
red) carried two reviewer blockers across three rounds: (1) cache/in-flight identity ignores the
configurable Antigravity destination so a baseUrl change replays stale rows and stale writers can
publish across generations; (2) every stored account bearer goes out through plain `fetch` to a
configurable host without the repository's pinned provider-outbound transport.

## Design (removes both blockers by construction)

- `supportsPerAccountQuota`: add `google-antigravity`.
- `fetchAccountQuota`: branch for `google-antigravity` → `fetchAntigravityUsageQuota(token, projectId)`,
where token comes from `getTokenForAccountQuotaProbe` (same refresh hygiene as Anthropic) and
projectId from that account's stored credential; missing projectId → throw → existing
negative-cache/unavailable path (never 0%).
- Destination: per-account probes go to the registry destination for the account's credential
(`https://daily-cloudcode-pa.googleapis.com`) only — not `config.baseUrl`. Per-account quota is
a display of Google's own accounting for that credential; a custom base URL is a routing choice,
not a second quota source. With a fixed destination the cache key `provider\0accountId` stays
correct and generation reconciliation keeps working unchanged (blocker 1 gone). Documented.
- Transport: `providerOutboundPost("google-antigravity", { baseUrl: DAILY }, url, ...)` — the shared
resolved/pinned transport with `redirect: "manual"` semantics; `providerRedirectError` → null
quota (blocker 2 gone). The provider-level probe keeps its current behavior (out of scope).
- Parsing: extract the existing `fetchAvailableModels` → `customWindows` classification into
`antigravityWindowsFromModels(body)` and reuse it in both paths so Gem/Cla semantics are identical.
- Route/UI: nothing to change — `/api/oauth/accounts?quota=1` already projects `quota.customWindows`
through the account list, and the dashboard renders customWindows for Anthropic rows today.

## Acceptance
- Two stored Antigravity accounts → two rows, each probed with its own bearer and its own project id,
to the fixed Google host; a private/redirecting destination is never given a token (transport test).
- Missing projectId → unavailable, no request, other account unaffected.
- Provider-level report unchanged (existing `tests/provider-quota.test.ts` green).
- `supportsPerAccountQuota("google-antigravity") === true`; unknown/failed never becomes 0%.
- tsc, privacy, focused: provider-account-quota, provider-quota, oauth-account-routes-related file.
- Close #2123 with credit for the account loop + token hygiene design and the reasons above.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# wp11 audit r1 — synthesis

Audit input: three reviewer rounds on PR #2123 (Ingwannu), which converged on two structural
blockers (destination-bound cache identity; pinned outbound transport for every stored bearer).
The plan removes both by fixing the per-account destination to Google's own host and routing through
`providerOutboundPost`, so no new cache dimension or reconciliation change is needed. Verdict
carried as pass for the plan; implementation is verified by the acceptance tests.

Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,19 @@ returns:
```

`--quota` adds a `QUOTA` column with each account's own usage, for providers that support a
per-account probe (Anthropic and Kiro today). It is opt-in because the proxy probes the upstream
per-account probe (Anthropic, Kiro, and Google Antigravity today). It is opt-in because the proxy probes the upstream
once per stored credential; the default listing stays a local read. `--refresh` bypasses the
cached result. An account with no per-account quota shows `-`, and one whose probe failed shows
`unavailable` — blank would read as "no usage" rather than "not measured". `--json` carries the
full breakdown per account, not just the summarized windows:

Google Antigravity rows carry the same `Gem` / `Cla` windows as the provider-level quota, computed
from that account's own credential and Cloud Code Assist project id. The per-account probe always
talks to Google's Cloud Code Assist host through the pinned outbound transport, regardless of a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify the transport pinning claim in proxy mode

When HTTP_PROXY/HTTPS_PROXY is configured and this public host does not match NO_PROXY, providerOutboundRequest deliberately sends the request through globalThis.fetch (src/lib/provider-outbound.ts:170-172) and warns that the final route and peer cannot be pinned locally. Therefore this unconditional user-facing statement is false in a supported environment; document that direct connections are resolved and pinned while proxy-mode connections preserve the configured proxy routing and its weaker peer guarantee.

AGENTS.md reference: docs-site/AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

configured `baseUrl`: a custom base URL is a routing choice for requests, not a second source of
Google's accounting for a stored credential. An account without a project id, or one whose probe
is redirected or fails, shows `unavailable`.

```text
$ ocx account list anthropic --quota
PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS QUOTA
Expand Down
101 changes: 75 additions & 26 deletions src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { resolveProviderApiKey } from "./key-store";
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
import { antigravityUserAgent } from "../adapters/client-fingerprint";
import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound";
import { apiKeyPoolEntryId } from "./api-keys";
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry";
Expand Down Expand Up @@ -1474,7 +1475,7 @@ export interface ProviderAccountQuota {

/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
export function supportsPerAccountQuota(provider: string): boolean {
return provider === "anthropic" || provider === "kiro";
return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity";
}

function accountCacheKey(provider: string, accountId: string): string {
Expand Down Expand Up @@ -1617,7 +1618,16 @@ async function fetchAccountQuota(
quota = kiroSnapshot?.quota ?? null;
} else {
const token = await getTokenForAccountQuotaProbe(provider, accountId);
quota = await fetchAnthropicUsageQuota(token);
if (provider === "google-antigravity") {
// Per-account Gem/Cla windows (#1082). The project id is part of the stored
// credential; without it the probe cannot be made, and that is "unavailable",
// never 0%.
const projectId = getAccountCredential(provider, accountId)?.projectId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the token and project ID in one credential snapshot

If the same Antigravity account is refreshed, reauthenticated, or re-imported after getTokenForAccountQuotaProbe resolves but before this second store read, the request can pair one credential generation's bearer with another generation's projectId. Google may reject the probe, or a token authorized for both projects may cache the wrong quota under this account for the TTL; return the bearer and project ID together from a full account-scoped snapshot while preserving the existing background-local-cli refresh guard. This token/credential pairing path is a repository-defined security boundary.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

if (!projectId) throw new Error("antigravity account has no project id");
quota = await fetchAntigravityUsageQuota(token, projectId);
} else {
quota = await fetchAnthropicUsageQuota(token);
}
}
if (!quota) {
// Preserve last-good bars and mark unavailable; advance TTL so failures
Expand Down Expand Up @@ -2179,31 +2189,10 @@ function antigravityUsedPercent(quotaInfo: Record<string, unknown>): number | un
return normalizePercent(100 - remaining);
}

async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
const credential = getCredential("google-antigravity");
if (!credential?.projectId) return null;
let accessToken: string;
try {
accessToken = await getValidAccessToken("google-antigravity");
} catch {
return null;
}
const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, "");
const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": antigravityUserAgent(),
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ project: credential.projectId }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null;
const body = asRecord(await readQuotaJson(response));
/** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */
function antigravityWindowsFromModels(body: Record<string, unknown> | null): ProviderQuotaWindow[] {
const models = asRecord(body?.models);
if (!models) return null;
if (!models) return [];

const windows = new Map<string, ProviderQuotaWindow>();
for (const [modelId, rawModelInfo] of Object.entries(models)) {
Expand All @@ -2226,6 +2215,66 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
const window = windows.get(label);
return window ? [window] : [];
});
return customWindows;
}

const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com";
let antigravityOutboundDependencies: ProviderOutboundDependencies = {};

/** Test seam: inject resolver/pinned transport for the per-account Antigravity probe. */
export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void {
antigravityOutboundDependencies = dependencies ?? {};
}

/**
* Per-account Antigravity quota (#1082). Always probes Google's own Cloud Code Assist host
* through the pinned provider-outbound transport: a configured `baseUrl` is a routing choice
* for requests, not a second source of Google's accounting for a stored credential, and fixing
* the destination keeps the `provider\0accountId` cache identity exact across config changes.
* A redirect or non-2xx yields null (unavailable), never a partial row.
*/
export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise<ProviderQuota | null> {
const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`;
const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": antigravityUserAgent(),
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ project: projectId }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, antigravityOutboundDependencies);
if (await providerRedirectError(response, url)) return null;
if (!response.ok) return null;
const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
if (customWindows.length === 0) return null;
return { customWindows, updatedAt: Date.now() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rank Antigravity accounts by the requested family

After a dashboard or --quota probe populates this cache for a multi-account Antigravity provider, generic OAuth pre-dispatch ranking in src/oauth/account-quota-rank.ts computes headroom from the maximum of every customWindows percentage without knowing the requested model. Because Gem and Cla are mutually exclusive model-family limits rather than concurrent account-wide limits, an account with exhausted Gemini quota but ample Claude quota is incorrectly deprioritized for Claude requests, potentially selecting an account whose Claude allowance is exhausted and causing avoidable 429s; make the ranking select the window for the requested Antigravity model family, or keep these family-specific rows out of generic account-wide ranking.

Useful? React with 👍 / 👎.

}

async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
const credential = getCredential("google-antigravity");
if (!credential?.projectId) return null;
let accessToken: string;
try {
accessToken = await getValidAccessToken("google-antigravity");
} catch {
return null;
}
const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, "");
const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": antigravityUserAgent(),
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ project: credential.projectId }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null;
const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
if (customWindows.length === 0) return null;
return report(provider, "google-antigravity:fetchAvailableModels", {
customWindows,
Expand Down
84 changes: 84 additions & 0 deletions tests/provider-account-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,87 @@ describe("fetchProviderAccountQuotas", () => {
expect(getCachedProviderAccountQuota("anthropic", first!.id)).toBeNull();
});
});

describe("google-antigravity per-account quota (#1082)", () => {
const { setAntigravityAccountQuotaTransportForTests } = require("../src/providers/quota") as typeof import("../src/providers/quota");
const { getAccountSet } = require("../src/oauth/store") as typeof import("../src/oauth/store");
const idFor = (email: string) => getAccountSet("google-antigravity")!.accounts.find(a => a.credential.email === email)!.id;

function antigravityBody(gemRemaining: number, claRemaining: number): string {
return JSON.stringify({
models: {
"gemini-3.7-flash": { displayName: "Gemini 3.7 Flash", quotaInfo: { remainingFraction: gemRemaining, resetTime: "2026-09-02T12:00:00Z" } },
"claude-opus-5": { displayName: "Claude Opus 5", quotaInfo: { remainingFraction: claRemaining, resetTime: "2026-09-02T18:00:00Z" } },
},
});
}

afterEach(() => setAntigravityAccountQuotaTransportForTests(null));

test("probes each account with its own bearer and project id on the fixed Google host over the pinned transport", async () => {
const expires = Date.now() + 60 * 60_000;
await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" });
await saveCredential("google-antigravity", { access: "agy-second", refresh: "r2", expires, projectId: "proj-second", accountId: "agy-b", email: "b@example.com" });
globalThis.fetch = (async () => { throw new Error("plain fetch must not be used for account bearers"); }) as typeof fetch;

const seen: Array<{ url: string; auth: string; project: string; address: string }> = [];
setAntigravityAccountQuotaTransportForTests({
resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }),
pinnedPost: async (url, pinned, body, _signal, requestOptions) => {
const auth = new Headers(requestOptions?.headers).get("authorization") ?? "";
const project = String(JSON.parse(String(body)).project);
seen.push({ url, auth, project, address: pinned.address });
return new Response(auth.endsWith("agy-first") ? antigravityBody(0.86, 0.38) : antigravityBody(0.97, 0.91), { status: 200, headers: { "content-type": "application/json" } });
},
});

expect(supportsPerAccountQuota("google-antigravity")).toBe(true);
const rows = await fetchProviderAccountQuotas("google-antigravity");
const byId = Object.fromEntries(rows.map(row => [row.accountId, row]));
const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")];
expect(Object.keys(byId).sort()).toEqual([idA, idB].sort());
const windows = (id: string) => byId[id]!.quota!.customWindows!.map(w => `${w.label}=${w.percent}`);
expect(windows(idA)).toEqual(["Gem=14", "Cla=62"]);
expect(windows(idB)).toEqual(["Gem=3", "Cla=9"]);
expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined();
expect(seen.map(s => `${s.auth}|${s.project}`).sort()).toEqual(["Bearer agy-first|proj-first", "Bearer agy-second|proj-second"]);
for (const s of seen) {
expect(s.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels");
expect(s.address).toBe("142.250.0.1");
}
});

test("a rejected destination never receives a bearer; the row is unavailable, not 0%", async () => {
const expires = Date.now() + 60 * 60_000;
await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" });
let posted = 0;
setAntigravityAccountQuotaTransportForTests({
resolveAddresses: async () => { throw new Error("provider URL resolves to private space"); },
pinnedPost: async () => { posted += 1; return new Response("{}", { status: 200 }); },
});
const rows = await fetchProviderAccountQuotas("google-antigravity");
expect(posted).toBe(0);
expect(rows).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]);
});

test("a redirecting upstream yields unavailable and the credential-less account is skipped without a request", async () => {
const expires = Date.now() + 60 * 60_000;
await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" });
await saveCredential("google-antigravity", { access: "agy-noproj", refresh: "r2", expires, accountId: "agy-np", email: "np@example.com" });
const projects: string[] = [];
setAntigravityAccountQuotaTransportForTests({
resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }),
pinnedPost: async (_url, _pinned, body) => {
projects.push(String(JSON.parse(String(body)).project));
return new Response(null, { status: 302, headers: { location: "https://elsewhere.example/x" } });
},
});
const rows = await fetchProviderAccountQuotas("google-antigravity");
expect(projects).toEqual(["proj-first"]);
for (const row of rows) {
expect(row.unavailable).toBe(true);
expect(row.quota).toBeNull();
}
});
});

Loading