diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 3752356b52..f485b011c5 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -369,8 +369,9 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `directGeminiWireRenames?` | `boolean` | Google only. Applies in AI Studio mode, including custom Google-compatible URLs. 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 and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; 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. Vertex API keys use this project endpoint when set; without it they use express mode, even if `GOOGLE_CLOUD_PROJECT` is set. ADC falls back to `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | | — | — | 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. | +| — | — | 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. Model discovery uses the CLI User-Agent; inference selects the User-Agent per model. If a usable quota summary or IDE models fallback has no model-tagged Argon measurement, one optional CLI-User-Agent models read adds exact Argon diagnostics. A tagged `Argon` / `Argon (Weekly)` summary window skips that read; a label alone does not. Missing or failed supplements keep the usable quota intact. | | — | — | Antigravity quota shows Gemini 4 Argon as separate `Argon` / `Argon (Weekly)` windows next to Gem and Cla. They are display-only: account ranking, quota exhaustion, reset ordering and reset notifications keep using the Gemini and Claude windows, and Argon requests are still judged by the Gemini window. | +| — | — | Argon Low/Medium/High are effort aliases. Matching window metadata, usage and reset are displayed once as `gemini-4-argon`; differing readings stay separate. Values are never added or averaged, and this display grouping does not merge the Gem summary or change routing. | | `location?` | `string` | Vertex location. API-key project endpoints default to `global` and ignore `GOOGLE_CLOUD_LOCATION`; ADC requires this field or the `GOOGLE_CLOUD_LOCATION` fallback. Gemini 4 Argon supports `global`, `us`, and `eu`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index 9ae43e9a67..7f81f3b097 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -1,4 +1,4 @@ -import { antigravityUserAgent } from "../../adapters/client-fingerprint"; +import { antigravityCliUserAgent, antigravityUserAgent } from "../../adapters/client-fingerprint"; import { DestinationDnsResolutionError } from "../../lib/destination-policy"; import { PinnedHttpError, type PinnedHttpErrorCode } from "../../lib/pinned-http"; import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../../lib/provider-outbound"; @@ -7,6 +7,7 @@ import { getAccountCredential, getCredential } from "../../oauth/store"; import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire"; import { report, type ProviderQuotaReport } from "./report-cache"; import { quotaCredentialIdentity } from "./account-cache"; +import { canonicalAntigravityUsageModel } from "../antigravity-models"; import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types"; export function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined { @@ -95,7 +96,17 @@ function antigravityWindowsFromModels(body: Record | null): Pro const windows = new Map(); const modelWindows: ProviderQuotaWindow[] = []; + const argonReadings = new Map }>>(); const labels = new Set(["Gem", "Cla"]); + const uniqueLabel = (baseLabel: string): string => { + let label = baseLabel; + for (let ordinal = 2; labels.has(label); ordinal += 1) { + const suffix = ` #${ordinal}`; + label = `${baseLabel.slice(0, 128 - suffix.length)}${suffix}`; + } + labels.add(label); + return label; + }; for (const [modelId, rawModelInfo] of Object.entries(models)) { const modelInfo = asRecord(rawModelInfo); if (!modelInfo) continue; @@ -110,23 +121,49 @@ function antigravityWindowsFromModels(body: Record | null): Pro // Keep model measurements as display/diagnostic evidence. They are deliberately // separate from the family summaries above; routing must not infer eligibility // from one model's counter or from a missing row. - if (modelId.trim() && modelId.length <= 128 && modelWindows.length < 256) { + if (modelId.trim() && modelId.length <= 128) { const tier = typeof quotaInfo.tier === "string" && quotaInfo.tier.length <= 64 ? quotaInfo.tier.trim().slice(0, Math.max(0, 125 - modelId.length)) : ""; - const baseLabel = `${modelId}${tier ? ` · ${tier}` : ""}`; - let modelLabel = baseLabel; - for (let ordinal = 2; labels.has(modelLabel); ordinal += 1) { - const suffix = ` #${ordinal}`; - modelLabel = `${baseLabel.slice(0, 128 - suffix.length)}${suffix}`; - } - labels.add(modelLabel); - modelWindows.push({ - label: modelLabel, + const measurement: ProviderQuotaWindow = { + label: uniqueLabel(`${modelId}${tier ? ` · ${tier}` : ""}`), modelId, percent, ...(resetAt !== undefined ? { resetAt } : {}), - }); + }; + if (canonicalAntigravityUsageModel(modelId) === "gemini-4-argon") { + // Match the full remaining window metadata, not just equal percentages. + const scope = Object.entries(quotaInfo) + .flatMap(([key, value]): Array<[string, unknown]> => { + if (["remainingFraction", "remainingPercentage", "resetTime"].includes(key)) return []; + const remaining = key === "remaining" ? asRecord(value) : null; + if (!remaining) return [[key, value]]; + const metadata = Object.fromEntries(Object.entries(remaining).filter(([name]) => !["remainingFraction", "remainingPercentage"].includes(name))); + return Object.keys(metadata).length ? [[key, metadata]] : []; + }) + .sort(([a], [b]) => a.localeCompare(b)); + const key = JSON.stringify([scope, percent, resetAt ?? quotaInfo.resetTime ?? null], (_name, value: unknown) => { + const record = asRecord(value); + return record ? Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b))) : value; + }); + const readings = argonReadings.get(key) ?? []; + const shared = readings.find(reading => !reading.models.has(modelId)); + if (shared) { + labels.delete(measurement.label); + if (shared.models.size === 1) { + labels.delete(shared.window.label); + shared.window.label = uniqueLabel(`gemini-4-argon${tier ? ` · ${tier}` : ""}`); + } + shared.models.add(modelId); + continue; + } + if (modelWindows.length < 256) { + readings.push({ window: measurement, models: new Set([modelId]) }); + argonReadings.set(key, readings); + } + } + if (modelWindows.length < 256) modelWindows.push(measurement); + else labels.delete(measurement.label); } } } @@ -306,6 +343,12 @@ function antigravityUnavailableFailure( return fallbackFailure; } +/** Only model-attributed evidence can suppress an optional Argon diagnostics read. */ +export function hasAntigravityArgonMeasurements(quota: ProviderQuota): boolean { + return quota.customWindows?.some(window => window.modelId !== undefined + && canonicalAntigravityUsageModel(window.modelId) === ARGON_POOL_MODEL_ID) ?? false; +} + export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { @@ -314,6 +357,31 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: }, body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }, antigravityOutboundDependencies); + // A tagged summary pool already supplies Argon; older summaries and the IDE list may not. + // Add only exact model rows, never a new Gem routing counter or evidence from labels alone. + const withArgonMeasurements = async (quota: ProviderQuota): Promise => { + const isArgon = (modelId: string) => canonicalAntigravityUsageModel(modelId) === "gemini-4-argon"; + if (hasAntigravityArgonMeasurements(quota)) return quota; + try { + const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL, antigravityCliUserAgent()); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) return quota; + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* Best-effort release of the optional response. */ } + return quota; + } + const models = asRecord(asRecord(await readQuotaJson(response))?.models); + if (!models) return quota; + const argonModels = Object.fromEntries(Object.entries(models).filter(([modelId]) => isArgon(modelId))); + const measurements = antigravityWindowsFromModels({ models: argonModels }).filter(window => window.modelId !== undefined); + if (!measurements.length) return quota; + let retainedModels = 0; + const previous = (quota.customWindows ?? []).filter(window => window.modelId === undefined || retainedModels++ < 256 - measurements.length); + return { ...quota, customWindows: [...previous, ...measurements] }; + } catch { + // An optional display probe cannot invalidate a usable account quota reading. + return quota; + } + }; let summaryFailure: QuotaFailureCode | undefined; try { let response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL); @@ -326,7 +394,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied"); if (response.ok) { const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response))); - if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" }; + if (quota) return { kind: "available", quota: await withArgonMeasurements(quota), source: "google-antigravity:retrieveUserQuotaSummary" }; } } catch (error) { // Existing behavior: summary transport/parse failure may recover through the models probe. @@ -344,7 +412,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: if (!customWindows.length) { return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "response_unusable")); } - return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" }; + return { kind: "available", quota: await withArgonMeasurements({ customWindows, updatedAt: Date.now() }), source: "google-antigravity:fetchAvailableModels" }; } catch (error) { // The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO. return { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a7526944ee..0e17540db9 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -578,7 +578,9 @@ Antigravity discovery in `src/providers/antigravity-models.ts` collapses complet Antigravity catalog rows carry the observed complete effort map from `src/providers/antigravity-models.ts` through the in-memory cache. `src/providers/antigravity-effort-families.ts` uses that evidence to group public rows and project suffix allowlists into the base selection, including both final catalog merge callers. Internal retained suffix rows remain available to combo resolution. Authoritative new-model reconciliation normalizes both the prior baseline and policy input before detecting arrivals: an existing suffix family is not a new model. All-disabled tiers transfer a base disable once; a known base keeps the operator's later choice. Genuinely new families still follow new-model policy. Saved defaults, combo targets, suffix disables and provider selections remain unchanged. A discovered ladder advertises only its wire-backed efforts unless explicitly overridden; unknown single-wire models gain no invented ladder. Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a deterministic usage identity in `src/providers/antigravity-models.ts`, independently of live discovery. This accounting map does not migrate saved routing selections; Claude 4.6 and unknown suffix identities remain unchanged. Antigravity model entitlement is per account: one account may list Claude 4.6, another only the Claude 5.5 suffix families, and Gemini 4 Argon only on Ultra/Enterprise. `ANTIGRAVITY_MODELS` is therefore a static superset (Gemini 3.8 Flash, Gemini 4 Argon, Claude Opus 5.5, Claude Sonnet 5.5), and `ANTIGRAVITY_KNOWN_PICKER_MODELS` keeps dropped bases known to routing, discovery collapse and usage identity. The catalog exposes only what the active account's authoritative discovery returns. `src/providers/antigravity-account-roster.ts` records each account's discovered roster (in memory, six-hour freshness, scoped to config home); `src/oauth/generic-account-failover.ts` ranks Antigravity accounts by it the way it ranks Kiro listings: a listing account wins, a known non-listing account is skipped while another remains, and an account without a fresh roster is unknown and stays usable. Gemini 4 Argon requests and Antigravity model discovery send the Antigravity CLI User-Agent; every other model keeps the IDE User-Agent. -`retrieveUserQuotaSummary` reports Gemini 4 Argon as its own group; `src/providers/quota/antigravity.ts` decides each bucket's family from its own id/name (Argon before Gemini, then Claude/3P/GPT), and only for an inconclusive bucket falls back to the group: Argon from the group display name alone (never its description), Gemini and Claude/3P/GPT from the existing display-name-plus-description rules. Argon buckets become `Argon`/`Argon (Weekly)` windows tagged with the canonical pool model `gemini-4-argon`, so they never fill or replace Gem/Cla in either group order and stay diagnostic like the model windows below; an Argon-only summary is a display report with no routing evidence, and the compact GUI summary and tray aggregation treat it like other diagnostic-only data. When the summary endpoint has no usable result, the same file preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries, where an Argon model never seeds Gem. Model windows carry the exact CCA wire ID and distinct labels of at most 128 characters for display and diagnostics; GUI summaries and native compact rows preserve the discriminator, retain details, and exclude model readings from headline and severity selection; account and provider routing ignore these rows and continue to use family summaries, and `src/quota/window-mapping.ts` keeps them out of quota-reset observation so one pool reset notifies once. A missing model row or failed probe is unknown evidence, not a model eligibility verdict. Per-account cache entries are bound to the current credential and project identity. Paused reads retain only current last-good measurements without probing; `src/providers/quota/account-row.ts` preserves private currentness callbacks through account-row projection until management publication. +`retrieveUserQuotaSummary` reports Gemini 4 Argon as its own group; `src/providers/quota/antigravity.ts` decides each bucket's family from its own id/name (Argon before Gemini, then Claude/3P/GPT), and only for an inconclusive bucket falls back to the group: Argon from the group display name alone (never its description), Gemini and Claude/3P/GPT from the existing display-name-plus-description rules. Argon buckets become `Argon`/`Argon (Weekly)` windows tagged with the canonical pool model `gemini-4-argon`, so they never fill or replace Gem/Cla in either group order and stay diagnostic like the model windows below; an Argon-only summary is a display report with no routing evidence, and the compact GUI summary and tray aggregation treat it like other diagnostic-only data. When the summary endpoint has no usable result, the same file preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries, where an Argon model never seeds Gem. A usable summary or fallback without model-tagged Argon measurements makes one optional CLI-User-Agent models read and adds only exact Argon model rows, retaining the 256-model-row ceiling and all family counters. The canonical tagged Argon summary pool skips that read; a label alone does not. An absent or failed supplement leaves the usable reading intact. Model windows carry the exact CCA wire ID and distinct labels of at most 128 characters for display and diagnostics; GUI summaries and native compact rows preserve the discriminator, retain details, and exclude model readings from headline and severity selection; account and provider routing ignore these rows and continue to use family summaries, and `src/quota/window-mapping.ts` keeps them out of quota-reset observation so one pool reset notifies once. A missing model row or failed probe is unknown evidence, not a model eligibility verdict. Per-account cache entries are bound to the current credential and project identity. Paused reads retain only current last-good measurements without probing; `src/providers/quota/account-row.ts` preserves private currentness callbacks through account-row projection until management publication. + +Identical Argon effort-alias readings collapse to one `gemini-4-argon` display row only when their window metadata, percentage and reset agree; one exact wire `modelId` remains as the diagnostic discriminator. Different counters, reset times or window metadata stay separate, as do repeated windows from the same wire ID. This display deduplication does not combine the Gem family summaries or add metadata requests. Account-row projection is loaded with `import()` after each account probe in `src/providers/quota.ts`; its cache dependencies do not create a static return edge into the quota facade. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0c9870e6e..7b1ce58c9c 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -270,7 +270,7 @@ 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. +`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 IDE models fallback. A usable reading may additionally collect CLI-only Argon model diagnostics with the CLI fingerprint through the same pinned accounting endpoint; supplement failure cannot invalidate that reading. General model discovery uses the CLI fingerprint; inference selects the fingerprint per wire model. 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. diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 141e28b527..214d8d8d88 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -742,9 +742,9 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(windows(idA)).toEqual(["Gem=14", "Gem (Weekly)=14", "Cla=62", "Cla (Weekly)=62"]); expect(windows(idB)).toEqual(["Gem=3", "Gem (Weekly)=3", "Cla=9", "Cla (Weekly)=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"]); + expect(seen.map(s => `${s.auth}|${s.project}`).sort()).toEqual(["Bearer agy-first|proj-first", "Bearer agy-first|proj-first", "Bearer agy-second|proj-second", "Bearer agy-second|proj-second"]); for (const s of seen) { - expect(s.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"); + expect([summaryUrl, modelsUrl]).toContain(s.url); expect(s.address).toBe("142.250.0.1"); } }); @@ -804,14 +804,14 @@ describe("google-antigravity per-account quota (#1082)", () => { }, }); const rows = await fetchProviderAccountQuotas("google-antigravity"); - const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + const urls = fallback ? [summaryUrl, modelsUrl, modelsUrl] : [summaryUrl, modelsUrl]; expect(resolved).toHaveLength(urls.length * 2); expect(posted).toHaveLength(urls.length * 2); - for (const url of urls) { - expect(resolved.filter(row => row.url === url)).toEqual([ - { url, benchmark: true, private: false, mihomo: true }, - { url, benchmark: true, private: false, mihomo: true }, - ]); + for (const url of new Set(urls)) { + expect(resolved.filter(row => row.url === url)).toEqual(Array.from( + { length: urls.filter(candidate => candidate === url).length * 2 }, + () => ({ url, benchmark: true, private: false, mihomo: true }), + )); } for (const [auth, project] of [["Bearer agy-first", "proj-first"], ["Bearer agy-second", "proj-second"]]) { expect(posted.filter(row => row.auth === auth)).toEqual(urls.map(url => ({ url, auth, project, address: "198.18.56.214", tls: true, signal: true }))); @@ -944,7 +944,7 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(seeded?.quota?.customWindows).toContainEqual({ label: "gemini-paused-model", modelId: "gemini-paused-model", percent: 24, }); - expect(posts).toBe(2); + expect(posts).toBe(3); if (replacement !== "unchanged") { await saveCredential("google-antigravity", { ...credential, ...(replacement !== "project" ? { access: "paused-model-second" } : {}), @@ -956,7 +956,7 @@ describe("google-antigravity per-account quota (#1082)", () => { const expected = replacement === "unchanged" ? seeded!.quota : null; expect(getCachedProviderAccountQuota("google-antigravity", accountId)).toEqual(expected); const [paused] = await fetchProviderAccountQuotas("google-antigravity", true); - expect(posts).toBe(2); + expect(posts).toBe(3); expect(paused?.quota).toEqual(expected); if (replacement !== "unchanged") expect(paused?.unavailable).toBe(true); else { @@ -967,7 +967,7 @@ describe("google-antigravity per-account quota (#1082)", () => { await saveCredential("google-antigravity", { ...credential, access: "paused-model-later" }); expect(paused?.isCurrent?.()).toBe(false); expect(getCachedProviderAccountQuota("google-antigravity", accountId)).toBeNull(); - expect(posts).toBe(2); + expect(posts).toBe(3); } }); @@ -1133,7 +1133,7 @@ describe("google-antigravity Fake-IP TUN quota probes without HTTP proxy (#3781) }, }); const rows = await fetchProviderAccountQuotas("google-antigravity"); - const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + const urls = fallback ? [summaryUrl, modelsUrl, modelsUrl] : [summaryUrl, modelsUrl]; expect(resolved).toEqual(urls.map(url => ({ url, benchmark: true, private: false, mihomo: true }))); expect(posted).toEqual(urls.map(url => ({ url, address: fakeIp, tls: true, auth: "Bearer agy-first", project: "proj-first" }))); expect(rows).toHaveLength(1); @@ -1228,14 +1228,14 @@ describe("paused generic OAuth accounts are never quota-probed", () => { }); await fetchProviderAccountQuotas("google-antigravity", true); - expect(seen.sort()).toEqual(["Bearer agy-held", "Bearer agy-live"]); + expect(seen.sort()).toEqual(["Bearer agy-held", "Bearer agy-held", "Bearer agy-live", "Bearer agy-live"]); const heldBefore = getCachedProviderAccountQuota("google-antigravity", heldId); expect(heldBefore).not.toBeNull(); await setAccountPaused("google-antigravity", heldId, true); seen.length = 0; const rows = await fetchProviderAccountQuotas("google-antigravity", true); - expect(seen).toEqual(["Bearer agy-live"]); + expect(seen).toEqual(["Bearer agy-live", "Bearer agy-live"]); expect(rows.find(row => row.accountId === heldId)?.quota).toEqual(heldBefore); await expect(getTokenForAccountQuotaProbe("google-antigravity", heldId)).rejects.toThrow("paused"); }); diff --git a/tests/providers/provider-antigravity-model-quota.test.ts b/tests/providers/provider-antigravity-model-quota.test.ts index be00103dd9..1fbc5884bb 100644 --- a/tests/providers/provider-antigravity-model-quota.test.ts +++ b/tests/providers/provider-antigravity-model-quota.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { antigravityCliUserAgent, antigravityUserAgent } from "../../src/adapters/client-fingerprint"; import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { accountHeadroomPercent, @@ -12,6 +13,7 @@ import { sweepExpiredProviderAccountQuotaRows } from "../../src/providers/quota/ import { ACCOUNT_QUOTA_TTL_MS } from "../../src/providers/quota-wire"; import { providerWindowObservations } from "../../src/quota/window-mapping"; import { + hasAntigravityArgonMeasurements, probeAntigravityUsageQuota, setAntigravityAccountQuotaTransportForTests, } from "../../src/providers/quota/antigravity"; @@ -47,6 +49,235 @@ function setModelsResponse(models: unknown): void { } describe("Antigravity model quota evidence", () => { + function argonTransport(summary: Response, cliModels: () => Response | Promise, ideModels?: unknown) { + const calls: Array<{ url: string; userAgent: string | null; authorization: string | null; project: unknown }> = []; + 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"), project: JSON.parse(body).project }); + if (url.endsWith("retrieveUserQuotaSummary")) return summary; + return headers.get("user-agent") === antigravityCliUserAgent() ? await cliModels() : Response.json({ models: ideModels }); + }, + }); + return calls; + } + + const quotaSummary = () => Response.json({ groups: [ + { displayName: "Gemini", buckets: [{ window: "5h", remainingFraction: 0.75 }, { window: "weekly", remainingFraction: 0.5 }] }, + { displayName: "Claude", buckets: [{ window: "5h", remainingFraction: 0.9 }, { window: "weekly", remainingFraction: 0.8 }] }, + ] }); + + test("Argon labels without a model discriminator never suppress the optional supplement", () => { + const quota = (window: { label: string; modelId?: string; percent: number }) => ({ updatedAt: Date.now(), customWindows: [window] }); + for (const label of ["gemini-4-argon", "Argon", "Argon (Weekly)"]) { + expect(hasAntigravityArgonMeasurements(quota({ label, percent: 80 }))).toBe(false); + } + for (const modelId of ["gemini-4-argon", "gemini-4-argon-high"]) { + expect(hasAntigravityArgonMeasurements(quota({ label: "diagnostic", modelId, percent: 80 }))).toBe(true); + } + expect(hasAntigravityArgonMeasurements(quota({ label: "gemini-4-argon", modelId: "gemini-4-argonaut", percent: 80 }))).toBe(false); + }); + + test("a usable summary supplements exact CLI-only Argon measurements without replacing family windows", async () => { + const resetTime = "2026-11-01T12:00:00Z"; + const calls = argonTransport(quotaSummary(), () => Response.json({ models: Object.fromEntries([ + ...Array.from({ length: 300 }, (_, i) => [`aux-${i}`, { quotaInfo: { remainingFraction: 0 } }]), + ...["low", "medium", "high"].map(effort => [`gemini-4-argon-${effort}`, { quotaInfo: { remainingFraction: 0.2, resetTime } }]), + ["gemini-4-argonaut", { quotaInfo: { remainingFraction: 0 } }], + ]) })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.source).toBe("google-antigravity:retrieveUserQuotaSummary"); + expect(result.quota.customWindows?.filter(window => window.modelId === undefined)).toEqual([ + { label: "Gem", percent: 25 }, { label: "Gem (Weekly)", percent: 50 }, + { label: "Cla", percent: 10 }, { label: "Cla (Weekly)", percent: 20 }, + ]); + expect(result.quota.customWindows?.filter(window => window.modelId !== undefined)).toEqual([ + { label: "gemini-4-argon", modelId: "gemini-4-argon-low", percent: 80, resetAt: Date.parse(resetTime) }, + ]); + expect(calls.map(call => call.userAgent)).toEqual([antigravityUserAgent(), antigravityCliUserAgent()]); + expect(calls.every(call => call.authorization === "Bearer synthetic-token" && call.project === "synthetic-project")).toBe(true); + expect(providerWindowObservations(result.quota).map(observation => observation.window)).toEqual(["custom:Gem", "custom:Gem (Weekly)", "custom:Cla", "custom:Cla (Weekly)"]); + expect(cachedProviderQuotaIsExhausted(result.quota, Date.now(), "gemini-4-argon")).toBe(false); + setCachedProviderAccountQuotaForTests("google-antigravity", "first", result.quota); + setCachedProviderAccountQuotaForTests("google-antigravity", "second", { updatedAt: Date.now(), customWindows: [{ label: "Gem", percent: 60 }] }); + expect(rankAccountsByHeadroom("google-antigravity", ["second", "first"], "gemini-4-argon")).toEqual(["first", "second"]); + }); + + test.each(["percent", "reset", "missing-reset", "invalid-reset", "window", "bucket"])("Argon %s differences retain separate measurements", async difference => { + const resetTime = "2026-11-01T12:00:00Z"; + const models = Object.fromEntries(["low", "medium", "high"].map(effort => { + const different = effort === "high"; + return [`gemini-4-argon-${effort}`, { quotaInfo: { + remainingFraction: different && difference === "percent" ? 0.4 : 0.2, + ...(different && difference === "missing-reset" ? {} : { resetTime: difference === "invalid-reset" ? `invalid-${different ? "high" : "low"}` : different && difference === "reset" ? "2026-11-02T12:00:00Z" : resetTime }), + ...(difference === "window" ? { tier: different ? "weekly" : "5h" } : {}), + ...(difference === "bucket" ? { bucketId: different ? "weekly-bucket" : "short-bucket" } : {}), + } }]; + })); + const calls = argonTransport(quotaSummary(), () => Response.json({ models })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + const rows = result.quota.customWindows!.filter(window => window.modelId !== undefined); + expect(rows).toHaveLength(2); + expect(rows[0]!.label).toBe(`gemini-4-argon${difference === "window" ? " · 5h" : ""}`); + expect(rows[0]!.percent).toBe(80); + expect(rows[1]!.modelId).toBe("gemini-4-argon-high"); + expect(rows[1]!.percent).toBe(difference === "percent" ? 60 : 80); + expect(rows[1]!.resetAt).toBe(difference === "missing-reset" || difference === "invalid-reset" ? undefined : Date.parse(difference === "reset" ? "2026-11-02T12:00:00Z" : resetTime)); + expect(calls).toHaveLength(2); + }); + + test("same-wire repeated Argon windows keep their multiplicity when effort aliases collapse", async () => { + const calls = argonTransport(quotaSummary(), () => Response.json({ models: Object.fromEntries( + ["low", "medium", "high"].map(effort => [`gemini-4-argon-${effort}`, { quotaInfos: [ + { remainingFraction: 0.2 }, { remainingFraction: 0.2 }, + ] }]), + ) })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows!.filter(window => window.modelId !== undefined)).toEqual([ + { label: "gemini-4-argon", modelId: "gemini-4-argon-low", percent: 80 }, + { label: "gemini-4-argon #2", modelId: "gemini-4-argon-low", percent: 80 }, + ]); + expect(calls).toHaveLength(2); + }); + + test("an IDE Argon ladder is also collapsed without an extra metadata request", async () => { + const calls = argonTransport(new Response(null, { status: 404 }), () => { throw new Error("unexpected CLI probe"); }, Object.fromEntries( + ["low", "medium", "high"].map(effort => [`gemini-4-argon-${effort}`, { quotaInfo: { remainingFraction: 0.2 } }]), + )); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows).toEqual([ + { label: "gemini-4-argon", modelId: "gemini-4-argon-low", percent: 80 }, + ]); + expect(calls).toHaveLength(2); + }); + + test("identical nested Argon window metadata ignores object key order", async () => { + argonTransport(quotaSummary(), () => Response.json({ models: { + "gemini-4-argon-low": { quotaInfo: { remaining: { remainingFraction: 0.2 }, window: { id: "short", kind: "5h" } } }, + "gemini-4-argon-high": { quotaInfo: { window: { kind: "5h", id: "short" }, remaining: { remainingFraction: 0.2 } } }, + } })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows!.filter(window => window.modelId !== undefined)).toEqual([ + { label: "gemini-4-argon", modelId: "gemini-4-argon-low", percent: 80 }, + ]); + }); + + test("equivalent nested Argon counter formats collapse but keep other remaining metadata", async () => { + argonTransport(quotaSummary(), () => Response.json({ models: { + "gemini-4-argon-low": { quotaInfo: { remaining: { remainingFraction: 0.2, bucket: "short" } } }, + "gemini-4-argon-medium": { quotaInfo: { remaining: { remainingPercentage: 0.2, bucket: "short" } } }, + "gemini-4-argon-high": { quotaInfo: { remaining: { remainingFraction: 0.2, bucket: "weekly" } } }, + } })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows!.filter(window => window.modelId !== undefined).map(window => window.label)).toEqual([ + "gemini-4-argon", "gemini-4-argon-high", + ]); + }); + + test("Argon aliases can complete an existing shared display row at the diagnostic ceiling", async () => { + setModelsResponse(Object.fromEntries([ + ...Array.from({ length: 255 }, (_, i) => [`aux-${i}`, { quotaInfo: { remainingFraction: 0.2 } }]), + ...["low", "medium", "high"].map(effort => [`gemini-4-argon-${effort}`, { quotaInfo: { remainingFraction: 0.2 } }]), + ])); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows!.filter(window => window.modelId !== undefined)).toHaveLength(256); + expect(result.quota.customWindows).toContainEqual({ label: "gemini-4-argon", modelId: "gemini-4-argon-low", percent: 80 }); + }); + + test.each(["empty", "denied", "redirect", "malformed", "transport"])("optional Argon %s keeps the usable summary unchanged", async failure => { + const calls = argonTransport(quotaSummary(), () => { + if (failure === "transport") throw new Error("synthetic transport failure"); + if (failure === "denied") return new Response(null, { status: 403 }); + if (failure === "redirect") return new Response(null, { status: 302, headers: { location: "https://redirect.example/" } }); + if (failure === "malformed") return new Response("invalid JSON"); + return Response.json({ models: { "gemini-4-argon-high": { quotaInfo: {} } } }); + }); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows).toHaveLength(4); + expect(result.quota.customWindows?.every(window => window.modelId === undefined)).toBe(true); + expect(calls).toHaveLength(2); + }); + + test("IDE fallback retains its family counters while CLI adds only Argon diagnostics", async () => { + const calls = argonTransport(new Response(null, { status: 404 }), () => Response.json({ models: { + "gemini-4-argon-high": { quotaInfo: { remainingFraction: 0 } }, + "gemini-3.8-flash-high": { quotaInfo: { remainingFraction: 0 } }, + } }), { "gemini-3.8-flash-high": { quotaInfo: { remainingFraction: 0.75 } } }); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows).toEqual([ + { label: "Gem", percent: 25 }, + { label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 25 }, + { label: "gemini-4-argon-high", modelId: "gemini-4-argon-high", percent: 100 }, + ]); + expect(calls.map(call => call.userAgent)).toEqual([antigravityUserAgent(), antigravityUserAgent(), antigravityCliUserAgent()]); + expect(cachedProviderQuotaIsExhausted(result.quota, Date.now(), "gemini-4-argon")).toBe(false); + }); + + test("an IDE model response already containing Argon avoids a duplicate display probe", async () => { + const calls = argonTransport(new Response(null, { status: 404 }), () => { throw new Error("unexpected duplicate probe"); }, { + "gemini-4-argon-high": { quotaInfo: { remainingFraction: 0.8 } }, + }); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + expect(calls).toHaveLength(2); + }); + + test.each([false, true])("an optional denial releases its body even when cancellation fails: %s", async cancelFails => { + let cancelled = false; + argonTransport(quotaSummary(), () => new Response(new ReadableStream({ cancel() { + cancelled = true; + if (cancelFails) throw new Error("synthetic cancellation failure"); + } }), { status: 403 })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + expect(cancelled).toBe(true); + }); + + test("Argon supplementation keeps the shared diagnostic ceiling and all family windows", async () => { + argonTransport(new Response(null, { status: 404 }), () => Response.json({ models: { + "gemini-4-argon-high": { quotaInfo: { remainingFraction: 0.2 } }, + } }), Object.fromEntries(Array.from({ length: 300 }, (_, i) => [`gemini-aux-${i}`, { quotaInfo: { remainingFraction: 0.75 } }]))); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.quota.customWindows?.filter(window => window.modelId !== undefined)).toHaveLength(256); + expect(result.quota.customWindows?.filter(window => window.modelId === undefined)).toEqual([{ label: "Gem", percent: 25 }]); + expect(result.quota.customWindows).toContainEqual({ label: "gemini-4-argon-high", modelId: "gemini-4-argon-high", percent: 80 }); + }); + + test.each([undefined, "unknown-model", "gemini-4-argon"])("Argon tier labels never affect reset-first ordering (%s)", async requestedModel => { + const resetTime = new Date(Date.now() + 3600_000).toISOString(); + argonTransport(quotaSummary(), () => Response.json({ models: { + "gemini-4-argon-high": { quotaInfoByTier: { weekly: { remainingFraction: 0.2, resetTime } } }, + } })); + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + setCachedProviderAccountQuotaForTests("google-antigravity", "first", { ...result.quota, customWindows: result.quota.customWindows?.filter(window => window.modelId === undefined) }); + setCachedProviderAccountQuotaForTests("google-antigravity", "second", result.quota); + expect(accountResetTimestamp("google-antigravity", "second", requestedModel)).toBeNull(); + expect(rankAccountsByResetFirst("google-antigravity", ["first", "second"], requestedModel)).toEqual(["first", "second"]); + }); + test("keeps bounded exact wire-model readings alongside unchanged first-seen family bars", async () => { setModelsResponse({ "gemini-3.8-flash-high": { diff --git a/tests/providers/provider-antigravity-quota-retry.test.ts b/tests/providers/provider-antigravity-quota-retry.test.ts index 97d4e28e96..db2aa7f775 100644 --- a/tests/providers/provider-antigravity-quota-retry.test.ts +++ b/tests/providers/provider-antigravity-quota-retry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { antigravityUserAgent } from "../../src/adapters/client-fingerprint"; +import { antigravityCliUserAgent, antigravityUserAgent } from "../../src/adapters/client-fingerprint"; import { probeAntigravityUsageQuota, setAntigravityAccountQuotaTransportForTests } from "../../src/providers/quota/antigravity"; import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; @@ -49,18 +49,18 @@ describe("Antigravity quota summary 403 compatibility retry (#5940)", () => { cancelled = true; if (cancelFails) throw new Error("cancel failed"); } }), { status: 403 }); - const calls = transport([denied, Response.json(summary)]); + const calls = transport([denied, Response.json(summary), Response.json({ models: {} })]); const result = await probeAntigravityUsageQuota("test-access", "test-project"); expect(cancelled).toBe(true); - expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0")]); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0"), expectedCall(modelsUrl, antigravityCliUserAgent())]); 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)]); + const calls = transport([Response.json(summary), Response.json({ models: {} })]); expect((await probeAntigravityUsageQuota("test-access", "test-project")).kind).toBe("available"); - expect(calls).toEqual([expectedCall(summaryUrl)]); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(modelsUrl, antigravityCliUserAgent())]); }); test("401 is not retried", async () => { @@ -79,9 +79,9 @@ describe("Antigravity quota summary 403 compatibility retry (#5940)", () => { 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)]); + const calls = transport([new Response(null, { status: 403 }), failure, Response.json(models), Response.json({ models: {} })]); expect(await probeAntigravityUsageQuota("test-access", "test-project")).toMatchObject({ kind: "available", source: "google-antigravity:fetchAvailableModels", quota: { customWindows: [{ label: "Gem", percent: 25 }, { label: "gemini", modelId: "gemini", percent: 25 }] } }); - expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0"), expectedCall(modelsUrl)]); + expect(calls).toEqual([expectedCall(summaryUrl), expectedCall(summaryUrl, "antigravity/1.0"), expectedCall(modelsUrl), expectedCall(modelsUrl, antigravityCliUserAgent())]); }); } }); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 9de61147f5..7acca3e9a6 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -916,7 +916,7 @@ describe("fetchProviderQuotaReports", () => { setAntigravityAccountQuotaTransportForTests({ resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), pinnedPost: async url => { - expect(url.endsWith("retrieveUserQuotaSummary")).toBe(true); + if (url.endsWith("fetchAvailableModels")) return Response.json({ models: {} }); return Response.json({ groups: [ { displayName: "Gemini Models", buckets: [{ window: "5h", remainingFraction: 0, resetTime }] }, { displayName: "Claude and GPT models", buckets: [{ window: "5h", remainingFraction: 1, resetTime }] }, @@ -3363,7 +3363,7 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]!.source).toBe("google-antigravity:retrieveUserQuotaSummary"); // Every bearer-carrying request went to Google's own host, not the configured loopback base. - expect(seen.map(entry => entry.hostname)).toEqual(["daily-cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com"]); + expect(seen.map(entry => entry.hostname)).toEqual(Array(4).fill("daily-cloudcode-pa.googleapis.com")); expect(seen.find(entry => entry.auth)?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"); expect(seen.find(entry => entry.auth)?.auth).toBe("Bearer agy-pinned-access"); expect(plainFetchUrls).toEqual([]); @@ -3472,7 +3472,7 @@ describe("fetchProviderQuotaReports", () => { }, }); const result = await fetchProviderQuotaReports(config(), true); - const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + const urls = fallback ? [summaryUrl, modelsUrl, modelsUrl] : [summaryUrl, modelsUrl]; expect(resolved).toEqual(urls.map(url => ({ url, benchmark: true, private: false, mihomo: true }))); expect(posted).toEqual(urls.map(url => ({ url, address: "198.18.56.214", tls: true, auth: "Bearer agy-canonical-access", body: JSON.stringify({ project: "agy-canonical-project" }), signal: true }))); expect(result.reports[0]?.source).toBe(fallback ? "google-antigravity:fetchAvailableModels" : "google-antigravity:retrieveUserQuotaSummary"); @@ -3503,8 +3503,8 @@ describe("fetchProviderQuotaReports", () => { { label: "Gem", percent: 25 }, { label: "gemini", modelId: "gemini", percent: 25 }, ]); - expect(resolved).toEqual([{ url: summaryUrl, private: false }, { url: modelsUrl, private: false }]); - expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(resolved).toEqual([{ url: summaryUrl, private: false }, { url: modelsUrl, private: false }, { url: modelsUrl, private: false }]); + expect(posted).toEqual([summaryUrl, modelsUrl, modelsUrl]); expect(plainFetchCalls).toEqual([]); }); } @@ -3560,7 +3560,7 @@ describe("fetchProviderQuotaReports", () => { { label: "Gem", percent: 25 }, { label: "gemini", modelId: "gemini", percent: 25 }, ]); - expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(posted).toEqual([summaryUrl, modelsUrl, modelsUrl]); expect(plainFetchCalls).toEqual([]); }); }