diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 700e060ca9b..2abc71e4bdd 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -366,8 +366,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. | | — | — | 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. Usable quota readings also make one optional CLI-User-Agent model-list read when Argon measurements are absent, so returned Argon model quotas appear alongside the Gem/Cla summary. These are display-only measurements; missing or failed Argon reads leave the existing quota intact and do not change account selection or exhaustion. Model discovery uses the CLI User-Agent; inference selects the User-Agent per model. | | `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | +| — | — | 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. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index d1b51a51f21..0a3630b062b 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -153,7 +153,7 @@ export function accountResetTimestamp( const quota = getCachedProviderAccountQuota(provider, accountId); if (!quota) return null; const family = classifyModelFamilyForQuota(provider, requestedModelId); - const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family)); + const windows = (quota.customWindows ?? []).filter(w => w.modelId === undefined && (!family || windowMatchesFamily(w.label, family))); const weekly = windows.find(w => /week/i.test(w.label)); let rawReset = weekly?.resetAt ?? quota.weeklyResetAt; if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") { diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index fda26590baf..71f25a0cbba 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 { @@ -72,7 +73,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; @@ -87,23 +98,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); } } } @@ -281,6 +318,31 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: }, body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }, antigravityOutboundDependencies); + // Summary family bars lack model diagnostics; the IDE list omits CLI-only Argon. Add only + // exact model rows: they remain display evidence, never a new Gem routing counter. + const withArgonMeasurements = async (quota: ProviderQuota): Promise => { + const isArgon = (modelId: string) => canonicalAntigravityUsageModel(modelId) === "gemini-4-argon"; + if (quota.customWindows?.some(window => window.modelId !== undefined && isArgon(window.modelId))) 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); @@ -293,7 +355,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. @@ -311,7 +373,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 80adbf549b4..0ff0c7c36b5 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. -When the `retrieveUserQuotaSummary` endpoint has no usable result, `src/providers/quota/antigravity.ts` preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries. 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. +When the `retrieveUserQuotaSummary` endpoint has no usable result, `src/providers/quota/antigravity.ts` preserves bounded per-model `quotaInfo` measurements from the IDE `fetchAvailableModels` fallback alongside the existing first-seen Gem/Cla family summaries. A usable summary or fallback that has no Argon model 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; 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 9481c2a8c7a..c0864282aa5 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 141e28b527c..214d8d8d883 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 9a486185534..ecb10a8b811 100644 --- a/tests/providers/provider-antigravity-model-quota.test.ts +++ b/tests/providers/provider-antigravity-model-quota.test.ts @@ -1,6 +1,7 @@ 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 { rankAccountsByHeadroom } from "../../src/oauth/account-quota-rank"; +import { accountResetTimestamp, rankAccountsByHeadroom, rankAccountsByResetFirst } from "../../src/oauth/account-quota-rank"; import { cachedProviderQuotaIsExhausted } from "../../src/combos/resolve"; import { getCachedProviderAccountQuota, setCachedProviderAccountQuotaForTests } from "../../src/providers/quota"; import { sweepExpiredProviderAccountQuotaRows } from "../../src/providers/quota/account-cache"; @@ -42,6 +43,224 @@ 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("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: "Gem", percent: 80 }, { 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 97d4e28e964..db2aa7f7759 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 9de61147f58..7acca3e9a60 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([]); }); }