diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index 0a1267303b3..bf9d0881ac2 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -163,6 +163,7 @@ public struct UsageAccountRow: Decodable, Equatable, Sendable { } public struct QuotaWindow: Decodable, Equatable, Sendable { + public let modelId: String? public let label: String? public let percent: Double? public let resetAt: Double? @@ -188,6 +189,7 @@ public struct QuotaReport: Decodable, Equatable, Sendable { /// A provider-agnostic view of quota, since the window key differs per provider. public struct NormalizedQuota: Equatable, Sendable { + public let modelId: String? public let provider: String public let providerLabel: String public let percent: Double? @@ -219,10 +221,10 @@ public extension QuotaReport { let name = label ?? provider var windows: [NormalizedQuota] = [] - func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?) { + func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?, _ modelId: String? = nil) { guard percent != nil || resetAt != nil else { return } windows.append(NormalizedQuota( - provider: provider, providerLabel: name, percent: percent, + modelId: modelId, provider: provider, providerLabel: name, percent: percent, windowLabel: windowLabel, resetAt: Self.date(from: resetAt) )) } @@ -232,7 +234,7 @@ public extension QuotaReport { append(quota?.monthlyPercent, "month", quota?.monthlyResetAt) for window in quota?.customWindows ?? [] { - append(window.percent, window.label ?? "window", window.resetAt) + append(window.percent, window.label ?? "window", window.resetAt, window.modelId) } return windows @@ -240,7 +242,8 @@ public extension QuotaReport { /// The single window that best represents current pressure, for the compact row. /// - /// Selection is **highest reported usage**, not longest horizon. Every window can + /// Diagnostic model rows are retained in details and excluded here. Selection is + /// **highest reported usage**, not longest horizon. Every aggregate window can /// stop work: a provider at 99% of a five-hour limit and 10% of its monthly limit is /// blocked right now, and showing the monthly 10% would paint that row green while /// the user cannot make a request. Ties break toward the longer horizon, since that @@ -250,7 +253,7 @@ public extension QuotaReport { /// em dash rather than a misleading zero. func normalized() -> NormalizedQuota { let name = label ?? provider - let windows = normalizedWindows() + let windows = normalizedWindows().filter { $0.modelId == nil } // Longer horizons rank higher only as a tie-breaker. func horizonRank(_ label: String) -> Int { @@ -271,7 +274,7 @@ public extension QuotaReport { } ?? windows.first return preferred ?? NormalizedQuota( - provider: provider, providerLabel: name, percent: nil, + modelId: nil, provider: provider, providerLabel: name, percent: nil, windowLabel: "—", resetAt: nil ) } diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index 0ec0012e621..f79580bbc49 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -230,6 +230,23 @@ enum ModelDecodingSuite { t.equal(report.normalized().percent, 12) } + t.test("quotas: model diagnostics stay detailed and cannot become compact pressure") { + let json = """ + {"provider":"google-antigravity","quota":{"customWindows":[ + {"label":"aux","modelId":"aux","percent":100}, + {"label":"Gem","percent":20}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().percent, 20) + t.equal(report.normalized().windowLabel, "Gem") + t.equal(report.normalizedWindows().count, 2) + t.equal(report.quota?.customWindows?.first?.modelId, "aux") + t.equal(report.normalizedWindows().first?.modelId, "aux") + let onlyModel = try decode(QuotaReport.self, + #"{"provider":"p","quota":{"customWindows":[{"label":"aux","modelId":"aux","percent":100}]}}"#) + t.isNil(onlyModel.normalized().percent, "model-only compact percent") + } + t.test("quotas: an absent quota normalizes to a nil percent") { let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() t.isNil(normalized.percent, "percent") diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index c98ac1c098d..f4ba4e6f112 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -697,7 +697,9 @@ from that account's own credential and Cloud Code Assist project id. The per-acc talks to Google's Cloud Code Assist host through the pinned outbound transport, regardless of a configured `baseUrl`: a custom base URL is a routing choice for requests, not a second source of Google's accounting for a stored credential. An account without a project id, or one whose probe -is redirected or fails, shows `unavailable`. +is redirected or fails, shows `unavailable`. When fallback model discovery supplies quota readings, +detailed displays retain up to 256 model diagnostics with distinct bounded labels. These rows do not +affect provider summary headlines, severity, account ranking, routing, or reset notifications. Devin rows come from Cognition's `GetUserStatus` for that account's own key, sent to its allowlisted api-server host. If an older credential has no host, the probe uses the configured diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 3651cadf52d..6881f6edbd5 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -673,6 +673,10 @@ also preserves an omitted setting, including the latest stored `false`; an expli boolean wins. See [Context windows on custom providers](/reference/configuration/providers/#context-windows-on-custom-providers) for its default-off, custom-provider-only behavior. +When Antigravity's quota-summary endpoint has no usable response, quota reports may also include +per-model Cloud Code Assist usage windows when the model-discovery response provides them. These +measurements are diagnostic; an absent model window does not establish that the model is unavailable. + The provider context-cap response includes `caps` (active limits) and `values` (last selected values, retained while disabled). Enabling a provider without `value` restores its selection, or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch diff --git a/gui/src/codex-quota-utils.ts b/gui/src/codex-quota-utils.ts index 57feab153f0..a19247d8580 100644 --- a/gui/src/codex-quota-utils.ts +++ b/gui/src/codex-quota-utils.ts @@ -13,7 +13,7 @@ export interface AccountQuota { shortObservedAt?: number; shortWindowSeconds?: number; monthlyResetAt?: number; - customWindows?: { label: string; percent: number; resetAt?: number }[]; + customWindows?: { label: string; percent: number; resetAt?: number; modelId?: string }[]; creditsUsd?: { used: number; limit: number; diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index fc72b0bb5f3..5763eb87899 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -9,6 +9,7 @@ import { type AccountQuota, normalizeQuotaForPlan } from "../codex-quota-utils"; export type QuotaWindowKey = "fiveHour" | "weekly" | "monthly"; export type QuotaBarRow = { + id: string; windowKey?: QuotaWindowKey; customLabel?: string; label: string; @@ -69,7 +70,7 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | const displayQuota = normalizeQuotaForPlan(quota, plan); if (!displayQuota) return []; // Intrinsic ranks for the standard slots; custom windows rank on their RAW labels. - const ranked: Array<{ rank: number; row: QuotaBarRow }> = []; + const ranked: Array<{ rank: number; row: Omit & { id?: string } }> = []; if (typeof displayQuota.fiveHourPercent === "number") { ranked.push({ rank: 0, @@ -112,6 +113,7 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | ranked.push({ rank: rawCustomWindowRank(customLabel), row: { + id: JSON.stringify([w.modelId ?? null, w.label, w.resetAt ?? null]), customLabel, label: localized, limitLabel: localized, @@ -122,7 +124,7 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | } if (displayQuota.creditsUsd && typeof displayQuota.creditsUsd.percent === "number") { const hasSubscriptionCreditsCustom = displayQuota.customWindows?.some( - w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + w => w.modelId === undefined && canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, ); if (!hasSubscriptionCreditsCustom) { const localized = localizeCustomQuotaLabel(SUBSCRIPTION_CREDITS_LABEL, t); @@ -138,7 +140,13 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }); } } - return ranked.sort((a, b) => a.rank - b.rank).map(entry => entry.row); + const identities = new Map(); + return ranked.sort((a, b) => a.rank - b.rank).map(({ row }) => { + const identity = row.id ?? row.windowKey ?? row.customLabel!; + const ordinal = identities.get(identity) ?? 0; + identities.set(identity, ordinal + 1); + return { ...row, id: JSON.stringify([identity, ordinal]) }; + }); } /** Max utilisation across known windows (for sorting providers by urgency). */ @@ -147,10 +155,11 @@ export function maxQuotaUtilisation(quota: AccountQuota | null): number { const vals = [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent] .filter((n): n is number => typeof n === "number"); for (const w of quota.customWindows ?? []) { + if (w.modelId !== undefined) continue; if (typeof w.percent === "number") vals.push(w.percent); } const hasSubscriptionCreditsCustom = quota.customWindows?.some( - w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + w => w.modelId === undefined && canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, ); if (!hasSubscriptionCreditsCustom && typeof quota.creditsUsd?.percent === "number") { vals.push(quota.creditsUsd.percent); @@ -329,7 +338,7 @@ export default function QuotaBars({ {observedLine} {rows.map(row => ( {observedLine} {rows.map(row => ( - + w && typeof w.label === 'string').map(w => ({ id: w.label, label: w.label, percent: w.percent, reset: w.resetAt })) : []), + ...(Array.isArray(quota.customWindows) ? quota.customWindows.filter(w => w && typeof w.label === 'string').map(w => ({ id: w.label, label: w.label, modelId: w.modelId, percent: w.percent, reset: w.resetAt })) : []), ]; // A window is listed only when it reports a percentage (zero included) or a reset time. Plans // differ in which windows they have: a weekly-only account has no 5-hour window, and showing a diff --git a/gui/src/provider-workspace/report.ts b/gui/src/provider-workspace/report.ts index bd79ac584e7..e97f90ba79b 100644 --- a/gui/src/provider-workspace/report.ts +++ b/gui/src/provider-workspace/report.ts @@ -148,6 +148,7 @@ function quotaFromUnknown(quota: unknown, fallbackUpdatedAt?: number): AccountQu label: row.label, percent: row.percent as number, ...(finite(row.resetAt) !== undefined ? { resetAt: row.resetAt as number } : {}), + ...(typeof row.modelId === "string" && row.modelId.length <= 128 ? { modelId: row.modelId } : {}), }]; }) : []; diff --git a/gui/src/quota-summary.ts b/gui/src/quota-summary.ts index f2d15c7a7e1..c79ab2608d3 100644 --- a/gui/src/quota-summary.ts +++ b/gui/src/quota-summary.ts @@ -17,6 +17,7 @@ export type QuotaSummarySeverity = "normal" | "warn" | "critical"; export interface QuotaSummaryWindow { id: string; + modelId?: string; /** Fixed window label (5h / weekly / monthly / credits). */ labelKey?: TKey; /** Provider-named window label, shown verbatim. */ @@ -57,13 +58,15 @@ function summaryWindows(quota: AccountQuota): QuotaSummaryWindow[] { const windows: QuotaSummaryWindow[] = quotaWindows(quota).map(window => { const percent = finite(window.percent) ? window.percent : undefined; const resetMs = resetTimestamp(window.reset); + const modelId = "modelId" in window ? window.modelId : undefined; return { id: window.id, + ...(modelId !== undefined ? { modelId } : {}), ...("key" in window && window.key ? { labelKey: window.key } : {}), ...("label" in window && typeof window.label === "string" ? { label: window.label } : {}), ...(percent !== undefined ? { percent } : {}), ...(resetMs !== null ? { resetAt: resetMs } : {}), - severity: quotaSeverity(percent), + severity: modelId === undefined ? quotaSeverity(percent) : "normal", }; }); const credits = quota.creditsUsd; @@ -81,7 +84,7 @@ function summaryWindows(quota: AccountQuota): QuotaSummaryWindow[] { } function pickHeadline(windows: QuotaSummaryWindow[]): QuotaSummaryWindow | undefined { - const measured = windows.filter(window => window.percent !== undefined); + const measured = windows.filter(window => window.modelId === undefined && window.percent !== undefined); const byId = new Map(measured.map(window => [window.id, window])); for (const id of HEADLINE_ORDER) { const hit = byId.get(id); diff --git a/gui/tests/provider-quota-model-id.test.ts b/gui/tests/provider-quota-model-id.test.ts new file mode 100644 index 00000000000..e66bd242f8a --- /dev/null +++ b/gui/tests/provider-quota-model-id.test.ts @@ -0,0 +1,72 @@ +import { buildQuotaSummary } from "../src/quota-summary"; +import { quotaWindows } from "../src/pages/tray-data"; +import { expect, test } from "bun:test"; +import { accountQuotaFromReport } from "../src/provider-workspace/report"; +import { buildQuotaRows, maxQuotaUtilisation } from "../src/components/QuotaBars"; + +test("provider quota projection preserves bounded exact Antigravity model IDs", () => { + expect(accountQuotaFromReport({ + updatedAt: 123, + quota: { + updatedAt: 123, + customWindows: [ + { label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 24 }, + { label: "legacy", modelId: "x".repeat(129), percent: 12 }, + ], + }, + })?.customWindows).toEqual([ + { label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 24 }, + { label: "legacy", percent: 12 }, + ]); +}); + +test("model diagnostic bars do not change provider urgency sorting", () => { + expect(maxQuotaUtilisation({ + updatedAt: 123, + customWindows: [ + { label: "Gem", percent: 36 }, + { label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 100 }, + ], + })).toBe(36); +}); + +test("diagnostic labels cannot suppress subscription credit urgency", () => { + expect(maxQuotaUtilisation({ + updatedAt: 123, + customWindows: [{ label: "Total subscription credits", modelId: "Total subscription credits", percent: 0 }], + creditsUsd: { used: 90, limit: 100, remaining: 10, percent: 90 }, + })).toBe(90); +}); + + +test("summary preserves model diagnostics without selecting them for headline or severity", () => { + const quota = { updatedAt: 123, customWindows: [ + { label: "aux", modelId: "aux", percent: 100 }, + { label: "Gem", percent: 20 }, + ] }; + const windows = quotaWindows(quota); + const rows = buildQuotaSummary({ google: { updatedAt: 123, quota } }, id => id); + expect(rows[0]?.headline.label).toBe("Gem"); + expect(windows[0]).toMatchObject({ modelId: "aux", percent: 100 }); + expect(rows[0]?.severity).toBe("normal"); + expect(rows[0]?.windows[0]).toMatchObject({ modelId: "aux", percent: 100, severity: "normal" }); + expect(buildQuotaSummary({ google: { updatedAt: 123, quota: { ...quota, customWindows: [quota.customWindows[0]!] } } }, id => id)).toEqual([]); +}); + +test("quota bars have unique stable identities for repeated labels", () => { + const customWindows = [ + { label: "same", modelId: "aux", percent: 10, resetAt: 123 }, + { label: "same", modelId: "aux", percent: 20, resetAt: 456 }, + { label: "same", modelId: "other", percent: 30 }, + ]; + const t = ((key: string) => key) as Parameters[2]; + const rows = buildQuotaRows({ updatedAt: 123, customWindows }, null, t); + expect(rows).toHaveLength(3); + expect(new Set(rows.map(row => row.id)).size).toBe(3); + const reversed = buildQuotaRows({ updatedAt: 123, customWindows: [...customWindows].reverse() }, null, t); + expect(reversed.map(row => row.id)).toEqual(rows.map(row => row.id).reverse()); + const repeated = buildQuotaRows({ updatedAt: 123, customWindows: [...customWindows, customWindows[0]!] }, null, t); + expect(new Set(repeated.map(row => row.id)).size).toBe(4); + const refreshed = buildQuotaRows({ updatedAt: 456, customWindows: customWindows.map(row => ({ ...row, percent: 99 })) }, null, t); + expect(refreshed.map(row => row.id)).toEqual(rows.map(row => row.id)); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 55c16307288..929cd2536d2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -49,6 +49,7 @@ "gui-pair-delivery.test.ts": "gui", "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", + "provider-antigravity-model-quota.test.ts": "providers", "antigravity-discovered-families.test.ts": "adapters/google", "provider-antigravity-effort-families.test.ts": "providers", "provider-antigravity-family-catalog.test.ts": "providers", diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index c4ad6cb4abe..f0bff707be0 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -306,6 +306,7 @@ export interface ProviderQuotaWindowDto { label: string; percent: number; resetAt?: number; + modelId?: string; } export interface ProviderQuotaDto extends CodexQuotaDto { diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 59559154706..a1c07fc1ab7 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -100,6 +100,9 @@ function quotaWindowExhausted(percent: number | undefined, resetAt: number | und const MODEL_FAMILY_WINDOW_LABELS = new Set(["fable", "opus", "sonnet"]); function customWindowAppliesToModel(window: ProviderQuotaWindow, model: string | undefined): boolean { + // Exact Antigravity model readings are diagnostic only. They are not permission or + // provider-wide exhaustion evidence until a separate eligibility contract consumes them. + if (window.modelId !== undefined) return false; if (window.scope !== "model" || model === undefined) return true; const family = window.label.trim().toLowerCase(); if (!MODEL_FAMILY_WINDOW_LABELS.has(family)) return true; diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index 0f73c58a2b0..d1b51a51f21 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -96,7 +96,7 @@ function headroomOf(provider: string, accountId: string, requestedModelId?: stri const family = classifyModelFamilyForQuota(provider, requestedModelId); if (family) { const percents = (quota.customWindows ?? []) - .filter(window => windowMatchesFamily(window.label, family)) + .filter(window => window.modelId === undefined && windowMatchesFamily(window.label, family)) .map(window => window.percent) .filter((value): value is number => typeof value === "number"); if (percents.length === 0) return null; @@ -106,7 +106,7 @@ function headroomOf(provider: string, accountId: string, requestedModelId?: stri quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent, - ...(quota.customWindows ?? []).map(window => window.percent), + ...(quota.customWindows ?? []).filter(window => window.modelId === undefined).map(window => window.percent), ].filter((value): value is number => typeof value === "number"); if (percents.length === 0) return null; return 100 - Math.max(...percents); diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index 508e964e281..4f1d319c5a3 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -19,6 +19,8 @@ export interface ProviderQuotaWindow { label: string; percent: number; resetAt?: number; + /** Exact CCA wire model measured by this window; display evidence only, not eligibility. */ + modelId?: string; /** * Set only when the PRODUCER proved this window covers one model family, structurally rather * than by reading its label: an Anthropic `seven_day_` body key, or a limit with diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 79c9ec2abc5..b67e1a867fc 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -53,7 +53,6 @@ import { hydrateAccountQuotaCache, mayCommitAccountQuotaKey, mayCommitProviderQuotaKey, - normalizeAnthropicQuota, supportsPerAccountQuota, type AccountQuotaCacheEntry, type ProviderAccountQuota, @@ -69,6 +68,7 @@ import { } from "./quota/vendor-probes-oauth"; import { fetchCommandCodeQuota, fetchKimiQuota, keyQuotaReaderForProvider } from "./quota/vendor-probes-key"; import { antigravityQuotaDiagnosticIdentity, fetchAntigravityQuota, probeAntigravityUsageQuota } from "./quota/antigravity"; +import { projectAccountQuotaRow } from "./quota/account-row"; import { persistKiroAccountState } from "./kiro-account-state-disk"; import { kiroProbeCurrent, kiroProbeIdentity } from "./quota/kiro-account-probe"; export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types"; @@ -425,7 +425,8 @@ async function fetchAccountQuota( providerConfig?: OcxProviderConfig, liveConfig?: OcxConfig, ): Promise { - if (accountQuotaProbeSkip(provider, accountId)) return accountQuotaProbeSkip(provider, accountId)!; + const skipped = accountQuotaProbeSkip(provider, accountId); + if (skipped) return skipped; if (isAnthropicInstanceId(provider)) return fetchAnthropicAccountQuota(provider, accountId, forceRefresh, liveConfig); if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig); if (provider === "kiro") hydrateAccountQuotaCache(); @@ -433,7 +434,8 @@ async function fetchAccountQuota( const writerGeneration = captureConfigGeneration(); const kiroIdentity = provider === "kiro" ? kiroProbeIdentity(accountId) : undefined; const cachedCandidate = accountQuotaCache.get(key); - const cached = provider !== "kiro" || cachedCandidate?.identity === kiroIdentity ? cachedCandidate : undefined; + const cached = (provider !== "kiro" || cachedCandidate?.identity === kiroIdentity) + && cachedCandidate?.isCurrent?.() !== false ? cachedCandidate : undefined; if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined }; return cached; @@ -455,6 +457,7 @@ async function fetchAccountQuota( try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); } catch { return false; } }; + const antigravityIsCurrent = () => quotaFailureIsCurrent(); const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {}; const unavailable = (): AccountQuotaCacheEntry => { const entry: AccountQuotaCacheEntry = { @@ -462,9 +465,11 @@ async function fetchAccountQuota( quota: cached?.quota ?? null, unavailable: true, ...(provider === "kiro" ? { identity: kiroIdentity } : {}), + ...(provider === "google-antigravity" ? { isCurrent: antigravityIsCurrent } : {}), ...diagnosticFields(), }; - if (mayCommitAccountQuotaKey(key, writerGeneration) && (provider !== "kiro" || kiroProbeCurrent(accountId, kiroIdentity))) { + if (entry.isCurrent?.() !== false && mayCommitAccountQuotaKey(key, writerGeneration) + && (provider !== "kiro" || kiroProbeCurrent(accountId, kiroIdentity))) { accountQuotaCache.set(key, entry); if (provider === "kiro") persistKiroAccountState(); sweepExpiredOnWrite(entry.ts); @@ -494,12 +499,15 @@ async function fetchAccountQuota( return { ts: Date.now(), quota: null, unavailable: true }; } } + if (provider === "google-antigravity" && !antigravityIsCurrent()) return { ts: Date.now(), quota: null, unavailable: true }; if (!quota) return unavailable(); const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota, ...(provider === "kiro" ? { identity: kiroIdentity } : {}), + ...(provider === "google-antigravity" ? { isCurrent: antigravityIsCurrent } : {}), }; if (mayCommitAccountQuotaKey(key, writerGeneration) + && entry.isCurrent?.() !== false && (provider !== "kiro" || kiroProbeCurrent(accountId, kiroIdentity))) { accountQuotaCache.set(key, entry); if (provider === "kiro") { commitKiroAccountUsageState(key, kiroSnapshot, kiroIdentity); persistKiroAccountState(); } @@ -531,21 +539,6 @@ export async function fetchProviderAccountQuotas( if (!set) return []; return mapQuotaRoster(set.accounts, async account => { const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig, liveConfig); - const result: ProviderAccountQuota = { - accountId: account.id, - quota: isAnthropicInstanceId(provider) ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, - ...(entry.unavailable ? { unavailable: true as const } : {}), - ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}), - }; - if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent }); - if (isAnthropicInstanceId(provider) && entry.isCurrent) Object.defineProperty(result, "isCurrent", { value: entry.isCurrent }); - if (!explicitAccountReader(provider)) return result; - const identity = entry.identity; - Object.defineProperty(result, "isCurrent", { value: () => { - if (entry.isCurrent) return entry.isCurrent(); - const credential = getAccountCredential(provider, account.id); - return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity); - } }); - return result; + return projectAccountQuotaRow(provider, account.id, entry, providerConfig); }); } diff --git a/src/providers/quota/account-cache.ts b/src/providers/quota/account-cache.ts index d2cc59b98f8..509bff2ba47 100644 --- a/src/providers/quota/account-cache.ts +++ b/src/providers/quota/account-cache.ts @@ -519,7 +519,8 @@ export async function getTokenForAccountQuotaProbe(provider: string, accountId: export function accountQuotaProbeSkip(provider: string, accountId: string): AccountQuotaCacheEntry | undefined { if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true }; if (getAccountCredentialWithStatus(provider, accountId)?.paused !== true) return undefined; - return accountQuotaCache.get(accountCacheKey(provider, accountId)) ?? { ts: Date.now(), quota: null, unavailable: true }; + const cached = accountQuotaCache.get(accountCacheKey(provider, accountId)); + return cached && cached.isCurrent?.() !== false ? cached : { ts: Date.now(), quota: null, unavailable: true }; } export function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined { diff --git a/src/providers/quota/account-row.ts b/src/providers/quota/account-row.ts new file mode 100644 index 00000000000..e5ab2785876 --- /dev/null +++ b/src/providers/quota/account-row.ts @@ -0,0 +1,34 @@ +import { getAccountCredential } from "../../oauth/store"; +import type { OcxProviderConfig } from "../../types"; +import { isAnthropicInstanceId } from "../anthropic-instance-id"; +import { + explicitAccountReader, explicitQuotaIdentity, normalizeAnthropicQuota, + type AccountQuotaCacheEntry, type ProviderAccountQuota, +} from "./account-cache"; + +/** Preserve private ownership fences until management's final publication check. */ +export function projectAccountQuotaRow( + provider: string, + accountId: string, + entry: AccountQuotaCacheEntry, + providerConfig?: OcxProviderConfig, +): ProviderAccountQuota { + const result: ProviderAccountQuota = { + accountId, + quota: isAnthropicInstanceId(provider) ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, + ...(entry.unavailable ? { unavailable: true as const } : {}), + ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}), + }; + if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent }); + if (explicitAccountReader(provider)) { + const identity = entry.identity; + Object.defineProperty(result, "isCurrent", { value: () => { + if (entry.isCurrent) return entry.isCurrent(); + const credential = getAccountCredential(provider, accountId); + return !!credential && (!identity || explicitQuotaIdentity(provider, accountId, providerConfig) === identity); + } }); + } else if (entry.isCurrent) { + Object.defineProperty(result, "isCurrent", { value: entry.isCurrent }); + } + return result; +} diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index 8e8c2428d0a..fda26590baf 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -71,19 +71,40 @@ function antigravityWindowsFromModels(body: Record | null): Pro if (!models) return []; const windows = new Map(); + const modelWindows: ProviderQuotaWindow[] = []; + const labels = new Set(["Gem", "Cla"]); for (const [modelId, rawModelInfo] of Object.entries(models)) { const modelInfo = asRecord(rawModelInfo); if (!modelInfo) continue; for (const quotaInfo of quotaInfoEntries(modelInfo)) { const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); - if (!label || windows.has(label)) continue; const percent = antigravityUsedPercent(quotaInfo); if (percent === undefined) continue; - windows.set(label, { - label, - percent, - ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), - }); + const resetAt = normalizeResetAt(quotaInfo.resetTime); + if (label && !windows.has(label)) { + windows.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + // 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) { + 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, + modelId, + percent, + ...(resetAt !== undefined ? { resetAt } : {}), + }); + } } } @@ -91,7 +112,7 @@ function antigravityWindowsFromModels(body: Record | null): Pro const window = windows.get(label); return window ? [window] : []; }); - return customWindows; + return [...customWindows, ...modelWindows]; } /** diff --git a/src/quota/window-mapping.ts b/src/quota/window-mapping.ts index 38c29465531..f9ce3c25f90 100644 --- a/src/quota/window-mapping.ts +++ b/src/quota/window-mapping.ts @@ -26,7 +26,7 @@ type ProviderLikeQuota = { weeklyResetAt?: number; monthlyPercent?: number; monthlyResetAt?: number; - customWindows?: ReadonlyArray<{ label: string; percent: number; resetAt?: number }>; + customWindows?: ReadonlyArray<{ label: string; percent: number; resetAt?: number; modelId?: string }>; }; function window( @@ -65,12 +65,15 @@ const NOMINAL_WINDOW_SECONDS: Readonly> = { }; function customWindows( - entries: ReadonlyArray<{ label: string; percent: number; resetAt?: number }> | undefined, + entries: ReadonlyArray<{ label: string; percent: number; resetAt?: number; modelId?: string }> | undefined, ): QuotaWindowObservation[] { if (!entries) return []; const out: QuotaWindowObservation[] = []; for (const entry of entries) { if (typeof entry?.label !== "string") continue; + // Exact-model rows (Antigravity diagnostics) restate the pool their family window already + // reports. Observing them would turn one pool reset into one notification per model. + if (entry.modelId !== undefined) continue; const mapped = window(`custom:${entry.label}`, entry.percent, entry.resetAt); if (mapped) out.push(mapped); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 808f61fc53b..d1db40f71d2 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -855,7 +855,7 @@ function providerRoutingQuota(config: OcxConfig, name: string, now: number): Pro // Removing search/MCP windows may leave only a timestamp. That is not inference evidence. const percentages = [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent, - ...(quota.customWindows ?? []).map(window => window.percent)]; + ...(quota.customWindows ?? []).filter(window => window.modelId === undefined).map(window => window.percent)]; const hasPercentage = percentages.some(value => typeof value === "number" && Number.isFinite(value) && value >= 0); const credits = quota.creditsUsd; const hasCredits = credits !== undefined && Number.isFinite(credits.percent) @@ -866,7 +866,7 @@ function providerRoutingQuota(config: OcxConfig, name: string, now: number): Pro let validUntil = quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS; if (state === "exhausted") { const resets = [quota.fiveHourResetAt, quota.weeklyResetAt, quota.monthlyResetAt, - ...(quota.customWindows ?? []).map(window => window.resetAt)] + ...(quota.customWindows ?? []).filter(window => window.modelId === undefined).map(window => window.resetAt)] .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset) && reset > now && reset < validUntil) .sort((left, right) => left - right); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 76b2d29daa9..1a979eb7289 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -578,7 +578,7 @@ 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. ## Combo model refusal evidence diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c2121adccf4..603c1666ff1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -108,6 +108,7 @@ "ollama-native-tool-continuations.test.ts": "providers/ollama", "gui-pair-delivery.test.ts": "gui", "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", + "provider-antigravity-model-quota.test.ts": "providers", "antigravity-discovered-families.test.ts": "adapters/google", "provider-antigravity-effort-families.test.ts": "providers", "provider-antigravity-family-catalog.test.ts": "providers", diff --git a/tests/gui/quota-bars-rows.test.ts b/tests/gui/quota-bars-rows.test.ts index 8ca5a275796..8951c04ecf9 100644 --- a/tests/gui/quota-bars-rows.test.ts +++ b/tests/gui/quota-bars-rows.test.ts @@ -20,6 +20,9 @@ function quota(overrides: Partial): AccountQuota { return { updatedAt: 0, ...overrides }; } +/** Row ids are React identity keys (model id, label, reset, ordinal); shape assertions ignore them. */ +const rowsWithoutIds = (rows: ReturnType) => rows.map(({ id: _id, ...row }) => row); + describe("buildQuotaRows (WP070)", () => { test("five-hour-only and weekly-only render single rows", () => { expect(buildQuotaRows(quota({ fiveHourPercent: 12 }), null, t).map(r => r.limitLabel)) @@ -74,7 +77,7 @@ describe("buildQuotaRows (WP070)", () => { const reported = quota({ creditsUsd: { used: 89.96, limit: 90, remaining: 0.04, percent: 99.96, expiresAt: 1790430938000 }, }); - expect(buildQuotaRows(reported, null, t)).toEqual([{ + expect(rowsWithoutIds(buildQuotaRows(reported, null, t))).toEqual([{ customLabel: "Total subscription credits", label: "quota.totalSubscriptionCredits", limitLabel: "quota.totalSubscriptionCredits", @@ -128,7 +131,7 @@ describe("buildQuotaRows (WP070)", () => { creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99, expiresAt: 1790517338000 }, }); for (const reported of [customOnly, withDirect]) { - expect(buildQuotaRows(reported, null, t)).toEqual([{ + expect(rowsWithoutIds(buildQuotaRows(reported, null, t))).toEqual([{ customLabel: "Total subscription credits", label: "quota.totalSubscriptionCredits", limitLabel: "quota.totalSubscriptionCredits", diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 55bcdb2cc32..141e28b527c 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -738,7 +738,7 @@ describe("google-antigravity per-account quota (#1082)", () => { const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")]; expect(Object.keys(byId).sort()).toEqual([idA, idB].sort()); - const windows = (id: string) => byId[id]!.quota!.customWindows!.map(w => `${w.label}=${w.percent}`); + const windows = (id: string) => byId[id]!.quota!.customWindows!.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`); 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(); @@ -772,7 +772,7 @@ describe("google-antigravity per-account quota (#1082)", () => { const rows = await fetchProviderAccountQuotas("google-antigravity"); const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")]; - const windows = (id: string) => byId[id]!.quota!.customWindows!.map(w => `${w.label}=${w.percent}`); + const windows = (id: string) => byId[id]!.quota!.customWindows!.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`); expect(windows(idA)).toEqual(["Gem=14", "Cla=62"]); expect(windows(idB)).toEqual(["Gem=3", "Cla=9"]); expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); @@ -817,8 +817,8 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(posted.filter(row => row.auth === auth)).toEqual(urls.map(url => ({ url, auth, project, address: "198.18.56.214", tls: true, signal: true }))); } const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); - expect(byId[idFor("a@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [14, 62] : [14, 14, 62, 62]); - expect(byId[idFor("b@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [3, 9] : [3, 3, 9, 9]); + expect(byId[idFor("a@example.com")]?.quota?.customWindows?.filter(w => w.modelId === undefined).map(w => w.percent)).toEqual(fallback ? [14, 62] : [14, 14, 62, 62]); + expect(byId[idFor("b@example.com")]?.quota?.customWindows?.filter(w => w.modelId === undefined).map(w => w.percent)).toEqual(fallback ? [3, 9] : [3, 3, 9, 9]); expect(plainFetchCalls).toBe(0); }); } @@ -896,7 +896,79 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(cached.quotaFailure).toBe("access_denied"); await saveCredential("google-antigravity", { ...credential, access: "agy-third" }); expect(cached.quotaFailureIsCurrent?.()).toBe(false); - expect((await fetchProviderAccountQuotas("google-antigravity"))[0]).not.toHaveProperty("quotaFailure"); + expect((await fetchProviderAccountQuotas("google-antigravity"))[0]).toHaveProperty("quotaFailure", "access_denied"); + }); + + test("same-id credential and project replacement invalidates cached model quota measurements", async () => { + const credential = { access: "agy-model-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-model-first", accountId: "agy-model", email: "model@example.com" }; + await saveCredential("google-antigravity", credential); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => url.endsWith("retrieveUserQuotaSummary") + ? new Response(null, { status: 404 }) + : new Response(JSON.stringify({ models: { + "gemini-3.8-flash-high": { quotaInfo: { remainingFraction: 0.76 } }, + } }), { status: 200, headers: { "content-type": "application/json" } }), + }); + + const [row] = await fetchProviderAccountQuotas("google-antigravity", true); + const accountId = idFor("model@example.com"); + expect(row?.quota?.customWindows).toContainEqual({ + label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 24, + }); + expect(getCachedProviderAccountQuota("google-antigravity", accountId)?.customWindows) + .toContainEqual({ label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 24 }); + + await saveCredential("google-antigravity", { ...credential, access: "agy-model-second", projectId: "proj-model-second" }); + expect(getCachedProviderAccountQuota("google-antigravity", accountId)).toBeNull(); + }); + + test.each(["unchanged", "grant", "project", "both"] as const)("paused model quota retains only current credential and project (%s)", async replacement => { + const { setAccountPaused } = await import("../../src/oauth/store"); + const credential = { access: "paused-model-first", refresh: "paused-refresh", expires: Date.now() + 3600_000, + projectId: "paused-project-first", accountId: "paused-model-user", email: "paused-model@example.com" }; + await saveCredential("google-antigravity", credential); + let posts = 0; + globalThis.fetch = (async () => { throw new Error("unexpected external quota transport"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => { + posts++; + return url.endsWith("retrieveUserQuotaSummary") ? new Response(null, { status: 404 }) + : Response.json({ models: { "gemini-paused-model": { quotaInfo: { remainingFraction: 0.76 } } } }); + }, + }); + const [seeded] = await fetchProviderAccountQuotas("google-antigravity", true); + const accountId = idFor("paused-model@example.com"); + expect(seeded?.quota?.customWindows).toContainEqual({ + label: "gemini-paused-model", modelId: "gemini-paused-model", percent: 24, + }); + expect(posts).toBe(2); + if (replacement !== "unchanged") { + await saveCredential("google-antigravity", { ...credential, + ...(replacement !== "project" ? { access: "paused-model-second" } : {}), + ...(replacement !== "grant" ? { projectId: "paused-project-second" } : {}), + }); + expect(idFor("paused-model@example.com")).toBe(accountId); + } + await setAccountPaused("google-antigravity", accountId, true); + 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(paused?.quota).toEqual(expected); + if (replacement !== "unchanged") expect(paused?.unavailable).toBe(true); + else { + expect(paused?.unavailable).toBeUndefined(); + expect(paused?.isCurrent?.()).toBe(true); + expect(Object.keys(paused!)).not.toContain("isCurrent"); + // A sibling roster read may await before management projects this already-returned row. + await saveCredential("google-antigravity", { ...credential, access: "paused-model-later" }); + expect(paused?.isCurrent?.()).toBe(false); + expect(getCachedProviderAccountQuota("google-antigravity", accountId)).toBeNull(); + expect(posts).toBe(2); + } }); test("NO_PROXY denial preserves an unavailable account row without sending its bearer", async () => { @@ -1068,7 +1140,7 @@ describe("google-antigravity Fake-IP TUN quota probes without HTTP proxy (#3781) expect(rows[0]!.accountId).toBe(idFor("a@example.com")); expect(rows[0]!.unavailable).toBeUndefined(); expect(rows[0]!.quotaFailure).toBeUndefined(); - expect(rows[0]!.quota?.customWindows?.map(w => `${w.label}=${w.percent}`)).toEqual(fallback ? ["Gem=14", "Cla=62"] : ["Gem=14", "Gem (Weekly)=14", "Cla=62", "Cla (Weekly)=62"]); + expect(rows[0]!.quota?.customWindows?.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`)).toEqual(fallback ? ["Gem=14", "Cla=62"] : ["Gem=14", "Gem (Weekly)=14", "Cla=62", "Cla (Weekly)=62"]); expect(plainFetchCalls).toBe(0); }); } diff --git a/tests/providers/provider-antigravity-model-quota.test.ts b/tests/providers/provider-antigravity-model-quota.test.ts new file mode 100644 index 00000000000..9a486185534 --- /dev/null +++ b/tests/providers/provider-antigravity-model-quota.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; +import { rankAccountsByHeadroom } 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"; +import { ACCOUNT_QUOTA_TTL_MS } from "../../src/providers/quota-wire"; +import { providerWindowObservations } from "../../src/quota/window-mapping"; +import { + probeAntigravityUsageQuota, + setAntigravityAccountQuotaTransportForTests, +} from "../../src/providers/quota/antigravity"; + +const proxyEnv = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyEnv.map(key => [key, process.env[key]])); + +beforeEach(() => { + for (const key of proxyEnv) delete process.env[key]; +}); + +afterEach(() => { + setAntigravityAccountQuotaTransportForTests(null); + setCachedProviderAccountQuotaForTests("google-antigravity", "first", null); + setCachedProviderAccountQuotaForTests("google-antigravity", "second", null); + for (const key of proxyEnv) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } +}); + +function setModelsResponse(models: unknown): void { + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.0.1", family: 4 }], + privateNetwork: false, + }), + pinnedPost: async url => url.endsWith("retrieveUserQuotaSummary") + ? new Response(null, { status: 404 }) + : new Response(JSON.stringify({ models }), { status: 200, headers: { "content-type": "application/json" } }), + }); +} + +describe("Antigravity model quota evidence", () => { + test("keeps bounded exact wire-model readings alongside unchanged first-seen family bars", async () => { + setModelsResponse({ + "gemini-3.8-flash-high": { + displayName: "Gemini Flash High", + quotaInfo: { remainingFraction: 0.64, resetTime: "2026-11-01T12:00:00Z" }, + }, + "claude-sonnet-4-6": { + quotaInfoByTier: { sonnet: { remainingFraction: 0.21, resetTime: "2026-11-02T12:00:00Z" } }, + }, + autocomplete: { quotaInfo: { remainingFraction: 0.01 } }, + malformed: { quotaInfo: { remainingFraction: "unknown" } }, + ["x".repeat(129)]: { quotaInfo: { remainingFraction: 0 } }, + }); + + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + + expect(result.quota.customWindows?.slice(0, 2)).toEqual([ + { label: "Gem", percent: 36, resetAt: Date.parse("2026-11-01T12:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-11-02T12:00:00Z") }, + ]); + expect(result.quota.customWindows).toContainEqual({ + label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 36, + resetAt: Date.parse("2026-11-01T12:00:00Z"), + }); + expect(result.quota.customWindows).toContainEqual({ + label: "claude-sonnet-4-6 · sonnet", modelId: "claude-sonnet-4-6", percent: 79, + resetAt: Date.parse("2026-11-02T12:00:00Z"), + }); + expect(result.quota.customWindows).toContainEqual({ + label: "autocomplete", modelId: "autocomplete", percent: 99, + }); + expect(result.quota.customWindows?.some(window => window.modelId === "malformed" || window.label.length > 128)).toBe(false); + }); + + test("multiple readings retain distinct bounded labels even when tiers truncate", async () => { + const longModel = "gemini-" + "x".repeat(115); + setModelsResponse({ + autocomplete: { quotaInfos: [{ remainingFraction: 0.8 }, { remainingFraction: 0.2 }] }, + [longModel]: { quotaInfoByTier: { + "shared-prefix-one": { remainingFraction: 0.7 }, + "shared-prefix-two": { remainingFraction: 0.1 }, + } }, + }); + 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(4); + expect(new Set(rows.map(window => window.label)).size).toBe(4); + expect(rows.every(window => window.label.length <= 128)).toBe(true); + expect(rows.map(window => window.percent)).toEqual([20, 80, 30, 90]); + expect(rows.map(window => window.modelId)).toEqual(["autocomplete", "autocomplete", longModel, longModel]); + }); + + test("bounds diagnostic rows without truncating later family evidence", async () => { + setModelsResponse(Object.fromEntries([ + ...Array.from({ length: 300 }, (_, i) => [`aux-${i}`, { quotaInfo: { remainingFraction: 0.5 } }]), + ["gemini-" + "x".repeat(129), { quotaInfo: { remainingFraction: 0.8 } }], + ["claude-final", { quotaInfo: { remainingFraction: 0.6 } }], + ])); + 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: 20 }, { label: "Cla", percent: 40 }, + ]); + }); + + test("a missing model listing remains unavailable rather than producing zero-usage evidence", async () => { + setModelsResponse(null); + expect(await probeAntigravityUsageQuota("synthetic-token", "synthetic-project")) + .toMatchObject({ kind: "unavailable", failure: "response_unusable" }); + }); + + test("a summary 403 remains an account-probe failure and does not fall through to model discovery", async () => { + let modelRequests = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.0.1", family: 4 }], + privateNetwork: false, + }), + pinnedPost: async url => { + if (url.endsWith("fetchAvailableModels")) modelRequests += 1; + return new Response(null, { status: 403 }); + }, + }); + expect(await probeAntigravityUsageQuota("synthetic-token", "synthetic-project")) + .toMatchObject({ kind: "unavailable", failure: "access_denied" }); + expect(modelRequests).toBe(0); + }); + + test.each([undefined, "gemini-3.8-flash-high"])("model-specific rows do not alter quota account ranking (%s)", requestedModel => { + setCachedProviderAccountQuotaForTests("google-antigravity", "first", { + updatedAt: Date.now(), + customWindows: [ + { label: "Gem", percent: 20 }, + { label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 100 }, + ], + }); + setCachedProviderAccountQuotaForTests("google-antigravity", "second", { + updatedAt: Date.now(), + customWindows: [ + { label: "Gem", percent: 40 }, + { label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 0 }, + ], + }); + expect(rankAccountsByHeadroom("google-antigravity", ["first", "second"], requestedModel)) + .toEqual(["first", "second"]); + }); + + test("model quota cache rows expire at the existing account quota TTL", () => { + setCachedProviderAccountQuotaForTests("google-antigravity", "first", { + updatedAt: Date.now(), + customWindows: [{ label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 24 }], + }); + expect(sweepExpiredProviderAccountQuotaRows(Date.now() + ACCOUNT_QUOTA_TTL_MS)).toBeGreaterThan(0); + expect(getCachedProviderAccountQuota("google-antigravity", "first")).toBeNull(); + expect(sweepExpiredProviderAccountQuotaRows(Date.now() + ACCOUNT_QUOTA_TTL_MS)).toBe(0); + }); + + test("model-specific diagnostic rows never prove combo target exhaustion", () => { + expect(cachedProviderQuotaIsExhausted({ + updatedAt: Date.now(), + customWindows: [{ label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 100 }], + }, Date.now(), "gemini-3.8-flash-high")).toBe(false); + }); + + test("model-specific diagnostic rows are not observed as quota-reset windows", async () => { + const resetTime = "2026-11-01T12:00:00Z"; + setModelsResponse(Object.fromEntries([ + ["gemini-3.8-flash-high", { quotaInfo: { remainingFraction: 0.5, resetTime } }], + ...Array.from({ length: 300 }, (_, i) => [`gemini-aux-${i}`, { quotaInfo: { remainingFraction: 0.5, resetTime } }]), + ])); + 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); + // One pool reset must notify once for its family, not once per diagnostic model row. + expect(providerWindowObservations(result.quota).map(observation => observation.window)).toEqual(["custom:Gem"]); + }); +}); diff --git a/tests/providers/provider-antigravity-quota-retry.test.ts b/tests/providers/provider-antigravity-quota-retry.test.ts index 6eea79bc3f9..97d4e28e964 100644 --- a/tests/providers/provider-antigravity-quota-retry.test.ts +++ b/tests/providers/provider-antigravity-quota-retry.test.ts @@ -80,7 +80,7 @@ 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)]); - expect(await probeAntigravityUsageQuota("test-access", "test-project")).toMatchObject({ kind: "available", source: "google-antigravity:fetchAvailableModels", quota: { customWindows: [{ label: "Gem", percent: 25 }] } }); + 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)]); }); } diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 237a159eae5..9de61147f58 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -391,6 +391,9 @@ describe("fetchProviderQuotaReports", () => { expect(byProvider["google-antigravity"]?.quota.customWindows).toEqual([ { label: "Gem", percent: 36, resetAt: Date.parse("2026-07-05T14:00:00Z") }, { label: "Cla", percent: 79, resetAt: Date.parse("2026-07-05T15:00:00Z") }, + { label: "gemini-3.6-flash-medium", modelId: "gemini-3.6-flash-medium", percent: 36, resetAt: Date.parse("2026-07-05T14:00:00Z") }, + { label: "claude-sonnet-4.6 · sonnet", modelId: "claude-sonnet-4.6", percent: 79, resetAt: Date.parse("2026-07-05T15:00:00Z") }, + { label: "autocomplete", modelId: "autocomplete", percent: 99, resetAt: Date.parse("2026-07-05T16:00:00Z") }, ]); expect(byProvider.cursor?.source).toBe("cursor:period-usage"); expect(byProvider.cursor?.reverseEngineered).toBe(true); @@ -3473,7 +3476,10 @@ describe("fetchProviderQuotaReports", () => { 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"); - expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: fallback ? 25 : 40 }]); + expect(result.reports[0]?.quota.customWindows).toEqual([ + { label: "Gem", percent: fallback ? 25 : 40 }, + ...(fallback ? [{ label: "gemini", modelId: "gemini", percent: 25 }] : []), + ]); expect(plainFetchCalls).toEqual([]); }); } @@ -3493,7 +3499,10 @@ describe("fetchProviderQuotaReports", () => { }, }); const result = await fetchProviderQuotaReports(config(baseUrl), true); - expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(result.reports[0]?.quota.customWindows).toEqual([ + { 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(plainFetchCalls).toEqual([]); @@ -3547,7 +3556,10 @@ describe("fetchProviderQuotaReports", () => { }, }); const result = await fetchProviderQuotaReports(config(), true); - expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(result.reports[0]?.quota.customWindows).toEqual([ + { label: "Gem", percent: 25 }, + { label: "gemini", modelId: "gemini", percent: 25 }, + ]); expect(posted).toEqual([summaryUrl, modelsUrl]); expect(plainFetchCalls).toEqual([]); }); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 611121994db..27071937e9d 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -238,6 +238,17 @@ describe("provider quota routing state", () => { expect(dto.reports.find((row: { provider: string }) => row.provider === "zai").quota.monthlyPercent).toBe(100); }); + test("model-specific diagnostic windows do not produce provider-wide routing evidence", async () => { + const cfg = quotaConfig(); + globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 8 } })) as typeof fetch; + await readQuota(cfg); + setCachedProviderQuotaForTests("openrouter", { + updatedAt: Date.now(), + customWindows: [{ label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 100 }], + }); + expect((await readQuota(cfg)).reports[0]?.routingQuota).toEqual({ state: "unknown" }); + }); + test("an exhausted OAuth account report stays display-only", async () => { const cfg = quotaConfig("kimi", "https://api.kimi.com/coding/v1"); cfg.providers.kimi!.authMode = "oauth"; diff --git a/tests/server/provider-account-quota-routes.test.ts b/tests/server/provider-account-quota-routes.test.ts index f6bf9a2329c..97cad35b2bd 100644 --- a/tests/server/provider-account-quota-routes.test.ts +++ b/tests/server/provider-account-quota-routes.test.ts @@ -21,6 +21,7 @@ beforeEach(() => { }); afterEach(() => { globalThis.fetch = originalFetch; + quotaApi.setAntigravityAccountQuotaTransportForTests(null); clearAccountQuotaCache(); clearProviderQuotaCache(); if (originalHome === undefined) delete process.env.OPENCODEX_HOME; @@ -50,6 +51,41 @@ async function read(path: string, config: OcxConfig) { return response; } +test.each([false, true])("paused Antigravity account API publishes only current model quota (replaced=%s)", async replaced => { + const { setAccountPaused } = await import("../../src/oauth/store"); + const credential = { access: "paused-api-first", refresh: "paused-api-refresh", expires: Date.now() + 3600_000, + projectId: "paused-api-project-first", accountId: "paused-api-user", email: "paused-api@example.test" }; + await saveCredential("google-antigravity", credential); + const config: OcxConfig = { port: 0, defaultProvider: "google-antigravity", providers: { + "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com" }, + } }; + let posts = 0; + globalThis.fetch = (async () => { throw new Error("unexpected external quota transport"); }) as typeof fetch; + quotaApi.setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => { + posts++; + return url.endsWith("retrieveUserQuotaSummary") ? new Response(null, { status: 404 }) + : Response.json({ models: { "gemini-paused-api-model": { quotaInfo: { remainingFraction: 0.76 } } } }); + }, + }); + const [seeded] = await quotaApi.fetchProviderAccountQuotas("google-antigravity", true); + const accountId = getAccountSet("google-antigravity")!.accounts[0]!.id; + expect(seeded?.quota?.customWindows).toContainEqual({ label: "gemini-paused-api-model", modelId: "gemini-paused-api-model", percent: 24 }); + expect(posts).toBe(2); + if (replaced) await saveCredential("google-antigravity", { ...credential, access: "paused-api-second", projectId: "paused-api-project-second" }); + await setAccountPaused("google-antigravity", accountId, true); + const body = await (await read("/api/oauth/accounts?provider=google-antigravity"a=1&refresh=1", config)).json(); + expect(body.accounts).toHaveLength(1); + expect(body.accounts[0].id).toBe(accountId); + expect(body.accounts[0].quota).toEqual(replaced ? null : seeded!.quota); + expect(body.accounts[0].quotaUnavailable).toBe(replaced); + expect(posts).toBe(2); + expect(JSON.stringify(body)).not.toContain("isCurrent"); +}); + + test("cheap and refresh-only key lists advertise capability without probing or changing config", async () => { const config = keyConfig(); const before = JSON.stringify(config);