diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e7e154cf659..61fc06fe5b0 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -286,6 +286,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `directGeminiWireRenames?` | `boolean` | Google only. Applies only to direct AI Studio requests. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex preserves the requested model ID, and Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | | `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | +| — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | | `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 26aad251318..309d1baaf2d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,7 @@ } }, "explicit": { + "provider-antigravity-quota-retry.test.ts": "providers", "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index 928b10e2570..8e8c2428d0a 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -253,16 +253,21 @@ 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 (response.status === 403) { + // Some valid accounts reject the IDE fingerprint only for quota accounting. + try { await response.body?.cancel(); } catch { /* Best-effort release before retry. */ } + response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, "antigravity/1.0"); + } 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) { diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 6e08a4b846e..2bc895a8bba 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -251,6 +251,8 @@ Pool quota producers and account commands follow the [bounded raw-observation co ## Account quota failure diagnostics +`src/providers/quota/antigravity.ts` retries a quota-summary 403 once with `User-Agent: antigravity/1.0`, releasing the first response body and preserving the bearer, project and pinned accounting endpoint. A 401 is not retried; redirects remain blocked, and a repeated 403 remains unavailable with `access_denied`. Other retry failures retain the models fallback, whose User-Agent remains the IDE fingerprint, as do discovery and inference. + Antigravity account quota probes expose only a closed `quotaFailure` category when the read is unavailable. Typed transport failures, rejected destinations, redirects, denied access, rate limits and unusable bodies are distinguished; successful fallback clears the earlier failure. The last attempted endpoint determines the diagnosis. A 401/403 category does not change account health, entitlement or routing eligibility. `src/providers/quota.ts` binds diagnoses to the probed credential/project and rechecks before cache reads and API projection. Reauthentication invalidates an old diagnosis independently of last-good quota bars. Private digests, callbacks and upstream error values are not serialized. The CLI and current/all-account dashboard views consume the same closed code; unknown codes and local management-read failures retain generic unavailable text. Codes are transient, never persisted quota evidence. Authenticated TUN field acceptance remains separate from deterministic transport coverage. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2eed0eac3af..4abc770e3ae 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "provider-antigravity-quota-retry.test.ts": "providers", "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 2ddae55d6dd..45f3e87e032 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -926,7 +926,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-antigravity-quota-retry.test.ts b/tests/providers/provider-antigravity-quota-retry.test.ts new file mode 100644 index 00000000000..6eea79bc3f9 --- /dev/null +++ b/tests/providers/provider-antigravity-quota-retry.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { antigravityUserAgent } from "../../src/adapters/client-fingerprint"; +import { probeAntigravityUsageQuota, setAntigravityAccountQuotaTransportForTests } from "../../src/providers/quota/antigravity"; + +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; + +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); +beforeEach(() => { for (const key of proxyKeys) delete process.env[key]; }); +afterEach(() => { + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } +}); + +const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; +const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; +const summary = { groups: [{ displayName: "Gemini", buckets: [{ window: "5h", remainingFraction: 0.6 }] }] }; +const models = { models: { gemini: { quotaInfo: { remainingFraction: 0.75 } } } }; + +function transport(responses: Array) { + const calls: Array<{ url: string; userAgent: string | null; authorization: string | null; body: string }> = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url, _address, body, _signal, options) => { + const headers = new Headers(options?.headers); + calls.push({ url, userAgent: headers.get("user-agent"), authorization: headers.get("authorization"), body }); + const response = responses.shift(); + if (!response) throw new Error("Unexpected extra quota request"); + if (response instanceof Error) throw response; + return response; + }, + }); + return calls; +} + +function expectedCall(url: string, userAgent = antigravityUserAgent()) { + return { url, userAgent, authorization: "Bearer test-access", body: JSON.stringify({ project: "test-project" }) }; +} + +afterEach(() => setAntigravityAccountQuotaTransportForTests(null)); + +describe("Antigravity quota summary 403 compatibility retry (#5940)", () => { + for (const cancelFails of [false, true]) { + test(`retries once with identical bearer and project, even when cancellation ${cancelFails ? "fails" : "succeeds"}`, async () => { + let cancelled = false; + const denied = new Response(new ReadableStream({ cancel() { + cancelled = true; + if (cancelFails) throw new Error("cancel failed"); + } }), { status: 403 }); + const calls = transport([denied, Response.json(summary)]); + const result = await probeAntigravityUsageQuota("test-access", "test-project"); + expect(cancelled).toBe(true); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0")]); + expect(result).toMatchObject({ kind: "available", source: "google-antigravity:retrieveUserQuotaSummary", quota: { customWindows: [{ label: "Gem", percent: 40 }] } }); + }); + } + + test("successful IDE summary needs no retry", async () => { + const calls = transport([Response.json(summary)]); + expect((await probeAntigravityUsageQuota("test-access", "test-project")).kind).toBe("available"); + expect(calls).toEqual([expectedCall(summaryUrl)]); + }); + + test("401 is not retried", async () => { + const calls = transport([new Response(null, { status: 401 })]); + expect(await probeAntigravityUsageQuota("test-access", "test-project")).toMatchObject({ kind: "unavailable", failure: "access_denied" }); + expect(calls).toEqual([expectedCall(summaryUrl)]); + }); + + for (const status of [401, 403, 302]) { + test(`retry status ${status} stops without further requests`, async () => { + const calls = transport([new Response(null, { status: 403 }), new Response(null, { status, headers: { location: "https://redirect.example/" } })]); + expect(await probeAntigravityUsageQuota("test-access", "test-project")).toMatchObject({ kind: "unavailable", failure: status === 302 ? "redirect_blocked" : "access_denied" }); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0")]); + }); + } + + for (const failure of [new Error("transport failed"), new Response(null, { status: 500 }), Response.json({})]) { + test(`retry failure (${failure instanceof Error ? "transport" : failure.status}) recovers through IDE models probe`, async () => { + const calls = transport([new Response(null, { status: 403 }), failure, Response.json(models)]); + expect(await probeAntigravityUsageQuota("test-access", "test-project")).toMatchObject({ kind: "available", source: "google-antigravity:fetchAvailableModels", quota: { customWindows: [{ label: "Gem", percent: 25 }] } }); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0"), expectedCall(modelsUrl)]); + }); + } +}); 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([]); }); }