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
26 changes: 23 additions & 3 deletions src/providers/quota/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.co
const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`;
const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`;

/**
* Compatibility fingerprint for quota accounting only.
*
* Some otherwise healthy Antigravity OAuth identities return HTTP 403 from
* retrieveUserQuotaSummary when the request uses the current IDE fingerprint while accepting the
* same bearer/project with the older Antigravity client family. Inference and model discovery must
* keep the IDE fingerprint because model availability is UA-gated; this one-shot fallback is
* deliberately scoped to a 403 from the quota-summary endpoint.
*/
export const ANTIGRAVITY_QUOTA_COMPAT_USER_AGENT = "antigravity/1.0";

/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */
export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean {
return name === "google-antigravity"
Expand Down Expand Up @@ -253,17 +264,26 @@ function antigravityUnavailableFailure(
}

export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise<AntigravityQuotaProbeResult> {
const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
headers: {
Accept: "application/json", "Content-Type": "application/json",
"User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`,
"User-Agent": userAgent, Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, antigravityOutboundDependencies);
let summaryFailure: QuotaFailureCode | undefined;
try {
const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL);
let response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL);
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked");
if (response.status === 403) {
// Google currently admits some healthy Antigravity accounts to inference and quota
// accounting while rejecting the newer IDE fingerprint on the summary endpoint itself.
// Retry exactly once with the older Antigravity client family; 401 is deliberately not
// retried because it is an authentication failure, not a fingerprint compatibility case.
try { await response.body?.cancel(); } catch { /* best effort */ }
response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, ANTIGRAVITY_QUOTA_COMPAT_USER_AGENT);
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked");
}
if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied");
if (response.ok) {
const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response)));
Expand Down
64 changes: 63 additions & 1 deletion tests/providers/provider-account-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
providerOAuthAccountQuotaMode,
} from "../../src/providers/quota";
import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env";
import { antigravityUserAgent } from "../../src/adapters/client-fingerprint";
import { ANTIGRAVITY_QUOTA_COMPAT_USER_AGENT } from "../../src/providers/quota/antigravity";
import { removeTreeWithRetry } from "../helpers/remove-tree";

const originalFetch = globalThis.fetch;
Expand Down Expand Up @@ -738,6 +740,66 @@ describe("google-antigravity per-account quota (#1082)", () => {
}
});

test("retries a 403 quota summary once with the Antigravity compatibility UA", async () => {
const expires = Date.now() + 60 * 60_000;
await saveCredential("google-antigravity", {
access: "agy-compat", refresh: "r-compat", expires,
projectId: "proj-compat", accountId: "agy-compat-account", email: "compat@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; userAgent: string | null }> = [];
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 userAgent = new Headers(requestOptions?.headers).get("user-agent");
seen.push({ url, userAgent });
if (seen.length === 1) return new Response(null, { status: 403 });
return new Response(antigravitySummaryBody(0.86, 0.38), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});

const [row] = await fetchProviderAccountQuotas("google-antigravity", true);
expect(row?.unavailable).not.toBe(true);
expect(row?.quotaFailure).toBeUndefined();
expect(row?.quota?.customWindows).toHaveLength(4);
expect(seen).toEqual([
{ url: summaryUrl, userAgent: antigravityUserAgent() },
{ url: summaryUrl, userAgent: ANTIGRAVITY_QUOTA_COMPAT_USER_AGENT },
]);
});

test("does not hide a 401 behind the quota compatibility retry", async () => {
const expires = Date.now() + 60 * 60_000;
await saveCredential("google-antigravity", {
access: "agy-auth-fail", refresh: "r-auth-fail", expires,
projectId: "proj-auth-fail", accountId: "agy-auth-fail-account", email: "auth-fail@example.com",
});
const seen: 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) => {
seen.push(new Headers(requestOptions?.headers).get("user-agent") ?? "");
return new Response(null, { status: 401 });
},
});

const [row] = await fetchProviderAccountQuotas("google-antigravity", true);
expect(row).toMatchObject({ unavailable: true, quotaFailure: "access_denied" });
expect(seen).toEqual([antigravityUserAgent()]);
});

test("falls back to fetchAvailableModels when retrieveUserQuotaSummary returns 404", 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" });
Expand Down Expand Up @@ -926,7 +988,7 @@ describe("google-antigravity per-account quota (#1082)", () => {
},
});
expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: status < 400 ? "redirect_blocked" : "access_denied" }]);
expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : [summaryUrl]);
expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : status === 403 ? [summaryUrl, summaryUrl] : [summaryUrl]);
expect(plainFetchCalls).toBe(0);
});
}
Expand Down
2 changes: 1 addition & 1 deletion tests/providers/provider-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3557,7 +3557,7 @@ describe("fetchProviderQuotaReports", () => {
pinnedPost: async url => { posted.push(url); return new Response(null, { status, headers: { location: modelsUrl } }); },
});
expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]);
expect(posted).toEqual([summaryUrl]);
expect(posted).toEqual(status === 403 ? [summaryUrl, summaryUrl] : [summaryUrl]);
expect(plainFetchCalls).toEqual([]);
});
}
Expand Down
Loading