diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index 928b10e2570..3fb6489b6ab 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -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" @@ -253,17 +264,26 @@ function antigravityUnavailableFailure( } export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - 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))); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 2ddae55d6dd..d09b33059b9 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -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; @@ -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" }); @@ -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); }); } diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 139daa50d04..2235e33f7b4 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -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([]); }); }