From fd55b6f8419f56dba4804157247c7328f190704a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 05:16:34 +0900 Subject: [PATCH] feat(quota): per-account Gem/Cla quota for Google Antigravity (#1082) supportsPerAccountQuota now includes google-antigravity. Each stored account is probed with its own bearer (same refresh hygiene as Anthropic) and its own Cloud Code Assist project id, and the existing Gem/Cla classification is shared with the provider-level probe. The per-account probe always targets Google's Cloud Code Assist host over the pinned provider-outbound transport, so a configured baseUrl cannot redirect stored bearers and the provider+account cache identity stays exact across config changes; redirects, blocked destinations, and a missing project id yield unavailable, never 0%. No UI change: the account list already projects customWindows. Supersedes PR #2123 (design credit: account loop and token hygiene). --- .../110_wp11_antigravity_account_quota.md | 37 +++++++ .../111_wp11_audit_r1_synthesis.md | 8 ++ .../docs/reference/cli/providers-accounts.md | 9 +- src/providers/quota.ts | 101 +++++++++++++----- tests/provider-account-quota.test.ts | 84 +++++++++++++++ 5 files changed, 212 insertions(+), 27 deletions(-) create mode 100644 devlog/_plan/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md create mode 100644 devlog/_plan/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md b/devlog/_plan/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md new file mode 100644 index 0000000000..70aeb89fea --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md @@ -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. + diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md b/devlog/_plan/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md new file mode 100644 index 0000000000..a99be52cca --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md @@ -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. + diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index becf94c5d3..598073e970 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -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 +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 diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 29342f2447..ca7e9eb993 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -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"; @@ -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 { @@ -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; + 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 @@ -2179,31 +2189,10 @@ function antigravityUsedPercent(quotaInfo: Record): number | un return normalizePercent(100 - remaining); } -async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { - 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 | null): ProviderQuotaWindow[] { const models = asRecord(body?.models); - if (!models) return null; + if (!models) return []; const windows = new Map(); for (const [modelId, rawModelInfo] of Object.entries(models)) { @@ -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 { + 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() }; +} + +async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { + 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, diff --git a/tests/provider-account-quota.test.ts b/tests/provider-account-quota.test.ts index 2bb21f13a2..cc6a7a5f46 100644 --- a/tests/provider-account-quota.test.ts +++ b/tests/provider-account-quota.test.ts @@ -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(); + } + }); +}); +