diff --git a/changelog.d/fixes/reset-aware-model-family.md b/changelog.d/fixes/reset-aware-model-family.md new file mode 100644 index 0000000000..75b65e7613 --- /dev/null +++ b/changelog.d/fixes/reset-aware-model-family.md @@ -0,0 +1 @@ +Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider. diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index a7941e072e..d27b98aae0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_03_reset_aware_model_family": "Own growth: open-sse/services/combo.ts 4036->4041 (+5). buildAutoCandidates now keys the reset-aware quota cache by getQuotaFetchScope and spreads requestedModel onto the connection so Gemini windows stay off a Claude-empty Antigravity account. Irreducible wiring at the existing fetchResetAwareQuotaWithCache call site; the family helper itself lives in antigravityQuotaFamily.ts. Covered by tests/unit/reset-aware-request-scope-12600.test.ts.", "_rebaseline_2026_09_03_overloaded_not_provider_breaker": "fix/overloaded-not-provider-breaker own growth: open-sse/services/combo.ts 4036->4075 (check-file-size split-newline, +39). Circuit-open pre-skip now records the breaker retryAfter and, when every target was skipped that way, waits the short reset via resolveCircuitOpenWaitDecision (new leaf in comboCooldownRetry.ts) instead of crystallizing ALL_TARGETS_SKIPPED in ~43ms. skippedForCircuitOpen / earliestCircuitOpenRetryMs reset each setTry so a later iteration cannot inherit a stale retryAfter. Irreducible at the existing ALL_TARGETS_SKIPPED chokepoint (same pattern as #7301/#8213 cooldown-wait). Predicate itself lives in circuitBreaker.ts / comboPredicates.ts / chatPredicates.ts, all under cap. Covered by tests/unit/overloaded-not-provider-breaker.test.ts + combo-cooldown-retry.test.ts.", "_rebaseline_2026_09_03_12649_free_tier_reaudit_gateways": "PR #12649 (fix/free-tier-quota-reaudit) own growth: src/shared/constants/providers/apikey/gateways.ts 1459->1462 (+3 = the nara authHint rewritten for the re-audited 7M/day plan now wraps to two lines, plus the Prettier reflow of two pre-existing >100-col authHint lines (oneminai, freebuff) that lint-staged enforces on any touch of the file; additive text at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #11786 seekai, #10987 logfare, #10531 freebuff). Covered by tests/unit/free-tier-reaudit-2026-09.test.ts and tests/unit/free-providers-batch-2026-07.test.ts.", "_rebaseline_2026_09_03_moonshot_native_quota": "PR feat/moonshot-native-quota own growth on release/v3.8.51: src/lib/db/migrationRunner.ts 1201->1206 (+5, case 172 retroactive guard for daily_quota_reset_* columns); src/sse/handlers/chat.ts 2434->2450 (+16, registerMoonshotQuotaFetcher + startup node scan at the existing quota-fetcher registration chokepoint); src/sse/services/auth.ts 3427->3450 (+23, resolveDailyResetForProvider + dailyReset arg on checkFallbackError); open-sse/services/accountFallback.ts 2422->2461 (+39, compatible-node credits_exhausted carve-out + TPD node-clock lock); tests/unit/account-fallback-service.test.ts 2008->2056 (+48, TPD/empty-wallet cases). Wiring at existing chokepoints; Moonshot host predicates, daily reset clock, and the balance fetcher live in new leaves under cap. Covered by tests/unit/moonshot-*.test.ts + account-fallback-service.test.ts (135/135 focused).", @@ -424,6 +425,7 @@ "open-sse/mcp-server/server.ts": 1572, "open-sse/services/accountFallback.ts": 2467, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, + "open-sse/services/combo.ts": 4041, "open-sse/services/combo.ts": 4075, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, diff --git a/open-sse/services/antigravityQuotaFamily.ts b/open-sse/services/antigravityQuotaFamily.ts index 94016c1109..07e0012b2b 100644 --- a/open-sse/services/antigravityQuotaFamily.ts +++ b/open-sse/services/antigravityQuotaFamily.ts @@ -55,6 +55,14 @@ export function getQuotaScopeLabelForProvider( return getAntigravityQuotaFamily(model) === "other" ? "model" : "family"; } +export function getQuotaFetchScope( + provider: string | null | undefined, + model: string | null | undefined +): string { + if (provider !== "antigravity" && provider !== "agy") return "*"; + return getQuotaScopedModelForProvider(provider, model) ?? "*"; +} + export function isAntigravityQuotaProvider(provider: string | null | undefined): boolean { return provider === "antigravity" || provider === "agy"; } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 635001b646..870a470749 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -69,6 +69,7 @@ import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLocko import { fetchCodexQuota } from "./codexQuotaFetcher.ts"; import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; import { resolveProviderId } from "../../src/shared/constants/providers.ts"; +import { getQuotaFetchScope } from "./antigravityQuotaFamily.ts"; import * as semaphore from "./rateLimitSemaphore.ts"; import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker"; import { parseModel } from "./model.ts"; @@ -596,14 +597,17 @@ export async function buildAutoCandidates( statusPenaltyReason = connectionStatusReason; } if (fetcher && target.connectionId) { - const quotaKey = `${provider}:${target.connectionId}`; + const quotaScope = getQuotaFetchScope(provider, target.modelStr); + const quotaKey = `${provider}:${target.connectionId}:${quotaScope}`; if (!quotaPromises.has(quotaKey)) { quotaPromises.set( quotaKey, fetchResetAwareQuotaWithCache({ provider, connectionId: target.connectionId, - connection, + connection: connection + ? { ...connection, requestedModel: target.modelStr } + : connection, fetcher, config: resetWindowConfig, log: {}, @@ -1393,7 +1397,8 @@ async function handleComboChatInner({ resilienceSettings, quotaCutoffResetWindowConfig, combo.name, - log, modelStr + log, + modelStr ); if (quotaCutoff.blocked) { log.info( diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts index a73d3628f4..ede1424105 100644 --- a/open-sse/services/combo/quotaExhaustionCutoff.ts +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -119,7 +119,7 @@ export async function resolveQuotaExhaustionCutoffForTarget( const quota = await fetchResetAwareQuotaWithCache({ provider, connectionId, - connection, + connection: connection ? { ...connection, requestedModel } : connection, fetcher, config: resetWindowConfig, log, diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index c4cb4c5b52..88401c0f43 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -46,6 +46,7 @@ import { } from "./quotaScoring.ts"; import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts"; import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersist.ts"; +import { getQuotaFetchScope } from "../antigravityQuotaFamily.ts"; import { isQuotaExhaustedForRequest } from "../../../src/domain/quotaCache.ts"; const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000; @@ -269,14 +270,17 @@ async function scoreQuotaAwareTargets({ const provider = getResetAwareProvider(target); const fetcher = provider ? getQuotaFetcher(provider) : null; if (fetcher && provider && target.connectionId) { - const quotaKey = `${provider}:${target.connectionId}`; + const quotaKey = `${provider}:${target.connectionId}:${getQuotaFetchScope(provider, target.modelStr)}`; if (!quotaPromises.has(quotaKey)) { + const connection = connectionById.get(target.connectionId); quotaPromises.set( quotaKey, fetchResetAwareQuotaWithCache({ provider, connectionId: target.connectionId, - connection: connectionById.get(target.connectionId), + connection: connection + ? { ...connection, requestedModel: target.modelStr } + : connection, fetcher, config, log, @@ -354,7 +358,10 @@ export async function fetchResetAwareQuotaWithCache({ log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; comboName: string; }): Promise { - const cacheKey = `${provider}:${connectionId}`; + const requestedModel = + typeof connection?.requestedModel === "string" ? connection.requestedModel : null; + const cacheScope = getQuotaFetchScope(provider, requestedModel); + const cacheKey = `${provider}:${connectionId}:${cacheScope}`; const ttlMs = config.quotaCacheTtlMs; const maxStaleMs = config.quotaCacheMaxStaleMs; const now = Date.now(); diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index 81419d5a6b..ac89aae30a 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -24,6 +24,10 @@ import { type QuotaFetcher, type QuotaInfo, } from "./quotaPreflight.ts"; +import { + getAntigravityQuotaFamily, + getQuotaFetchScope, +} from "./antigravityQuotaFamily.ts"; type UsageFetcher = ( connection: Parameters[0], @@ -54,7 +58,7 @@ export function __agePendingForceRefreshForTests( connectionId: string, ageMs: number ): void { - pendingForceRefresh.set(cacheKey(provider, connectionId), Date.now() - ageMs); + pendingForceRefresh.set(connectionKey(provider, connectionId), Date.now() - ageMs); } /** Test-only: backdate a convert-null miss so the 60s hammer-guard is unit-testable. */ @@ -63,7 +67,7 @@ export function __agePendingForceRefreshMissForTests( connectionId: string, ageMs: number ): void { - pendingForceRefreshMiss.set(cacheKey(provider, connectionId), Date.now() - ageMs); + pendingForceRefreshMiss.set(connectionKey(provider, connectionId), Date.now() - ageMs); } /** Test-only: drop all wrapper/flag maps so tests cannot leak across ids. */ @@ -80,10 +84,25 @@ interface CacheEntry { const cache = new Map(); -function cacheKey(provider: string, connectionId: string): string { +function connectionKey(provider: string, connectionId: string): string { return `${provider.trim()}::${connectionId.trim()}`; } +function quotaCacheScope( + provider: string, + requestedModel?: string | null +): string { + return getQuotaFetchScope(provider, requestedModel); +} + +function cacheKey( + provider: string, + connectionId: string, + requestedModel?: string | null +): string { + return `${connectionKey(provider, connectionId)}::${quotaCacheScope(provider, requestedModel)}`; +} + function dropExpiredPendingForceRefresh(key: string, now: number): boolean { const stampedAt = pendingForceRefresh.get(key); if (stampedAt === undefined) return true; @@ -216,7 +235,15 @@ interface ConnectionInputs { * / shape-unknown / missing). Exported for unit testing — the production path * is `fetchGenericQuota`, which adds caching + the upstream call. */ -export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null { +type UsageToQuotaContext = { + requestedModel?: string | null; + provider?: string | null; +}; + +export function convertUsageToQuotaInfo( + usage: unknown, + context: UsageToQuotaContext = {} +): QuotaInfo | null { if (!usage || typeof usage !== "object") return null; const usageRecord = usage as Record; if ( @@ -235,31 +262,51 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null { } const windows: Record = {}; - let worstPercent = 0; - let worstResetAt: string | null = null; for (const [name, entry] of Object.entries(quotasObj as Record)) { const percentUsed = percentUsedForQuota(entry); if (percentUsed === null) continue; - const resetAt = resetAtForQuota(entry); - windows[name] = { percentUsed, resetAt }; - if (percentUsed > worstPercent) { - worstPercent = percentUsed; - worstResetAt = resetAt; - } + windows[name] = { percentUsed, resetAt: resetAtForQuota(entry) }; } if (Object.keys(windows).length === 0) return null; - const normalized = normalizeQuotaWindows(windows); + const requestedFamily = + isAntigravityProvider(context.provider) && context.requestedModel + ? getAntigravityQuotaFamily(context.requestedModel) + : null; + const providerScopedWindows = + requestedFamily === "gemini" || requestedFamily === "claude" + ? Object.fromEntries( + Object.entries(windows).filter(([key]) => { + if (key.endsWith("_weekly")) { + return antigravityWeeklyWindowMatchesFamily(key, requestedFamily); + } + return getAntigravityQuotaFamily(key) === requestedFamily; + }) + ) + : windows; + if (Object.keys(providerScopedWindows).length === 0) return null; + + const normalized = normalizeQuotaWindows(providerScopedWindows, context); + const scopedEntries = Object.values(providerScopedWindows); + const percentUsed = scopedEntries.reduce( + (worst, entry) => Math.max(worst, entry.percentUsed), + 0 + ); + const resetAt = + scopedEntries.reduce<{ percentUsed: number; resetAt: string | null } | null>( + (worst, entry) => (!worst || entry.percentUsed > worst.percentUsed ? entry : worst), + null + )?.resetAt ?? null; return { used: 0, total: 0, - percentUsed: worstPercent, - resetAt: worstResetAt, - windows, + percentUsed, + resetAt, + windows: providerScopedWindows, ...normalized, - limitReached: worstPercent >= 1 - 1e-9, + limitReached: percentUsed >= 1 - 1e-9, }; } @@ -269,12 +316,29 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null { * naming convention. * * - Claude: "session (5h)" → window5h, "weekly (7d)" → window7d - * - Antigravity: worst per-model quota → window5h; worst *_weekly quota → window7d + * - Antigravity: requested-family model quota → window5h; matching family weekly quota → window7d */ +function isAntigravityProvider(provider: string | null | undefined): boolean { + return provider === "antigravity" || provider === "agy"; +} + +function antigravityWeeklyWindowMatchesFamily( + key: string, + family: "gemini" | "claude" +): boolean { + if (!key.endsWith("_weekly")) return false; + return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly"; +} + function normalizeQuotaWindows( - windows: Record + windows: Record, + context: UsageToQuotaContext ): Record { const normalized: Record = {}; + const requestedFamily = + isAntigravityProvider(context.provider) && context.requestedModel + ? getAntigravityQuotaFamily(context.requestedModel) + : null; // Claude-style explicit time windows. if (windows["session (5h)"] && !normalized.window5h) { @@ -284,22 +348,31 @@ function normalizeQuotaWindows( normalized.window7d = windows["weekly (7d)"]; } - // Antigravity-style per-model 5h windows: pick the worst (most used) model quota. + // Antigravity-style per-model windows: pick worst only inside requested family. const modelWindows = Object.entries(windows).filter( ([key]) => key !== "credits" && !key.endsWith("_weekly") && !key.startsWith("window") && !key.includes("(5h)") && - !key.includes("(7d)") + !key.includes("(7d)") && + (requestedFamily === null || + requestedFamily === "other" || + getAntigravityQuotaFamily(key) === requestedFamily) ); if (modelWindows.length > 0 && !normalized.window5h) { const worst = modelWindows.reduce((a, b) => (a[1].percentUsed > b[1].percentUsed ? a : b)); normalized.window5h = worst[1]; } - // Antigravity-style weekly family buckets: pick the worst *_weekly quota. - const weeklyWindows = Object.entries(windows).filter(([key]) => key.endsWith("_weekly")); + // Antigravity-style weekly buckets: pick worst only inside requested family. + const weeklyWindows = Object.entries(windows).filter(([key]) => { + const hasFamilyScope = requestedFamily === "gemini" || requestedFamily === "claude"; + return ( + key.endsWith("_weekly") && + (!hasFamilyScope || antigravityWeeklyWindowMatchesFamily(key, requestedFamily)) + ); + }); if (weeklyWindows.length > 0 && !normalized.window7d) { const worst = weeklyWindows.reduce((a, b) => (a[1].percentUsed > b[1].percentUsed ? a : b)); normalized.window7d = worst[1]; @@ -320,18 +393,21 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) const provider = typeof conn.provider === "string" ? conn.provider.trim() : ""; if (!provider) return null; - const key = cacheKey(provider, connectionId); + const requestedModel = + typeof connection.requestedModel === "string" ? connection.requestedModel : undefined; + const key = cacheKey(provider, connectionId, requestedModel); + const forceKey = connectionKey(provider, connectionId); const now = Date.now(); - const forceRefresh = isPendingForceRefresh(key, now); + const forceRefresh = isPendingForceRefresh(forceKey, now); const hit = cachedQuotaIfFresh(key, forceRefresh, now); if (hit) return hit; // convert-null / throw keep the force-refresh flag (agy inner caches are // still stale) but must not hammer those endpoints on every routing tick. - if (isForceRefreshMissCooling(key, forceRefresh, now)) return null; + if (isForceRefreshMissCooling(forceKey, forceRefresh, now)) return null; // Capture before await: a 429 during fetchUsage re-stamps this; writing // the pre-429 snapshot would wipe that flag and recache stale quota. - const refreshStamp = pendingForceRefresh.get(key); + const refreshStamp = pendingForceRefresh.get(forceKey); let usage: unknown; try { @@ -340,28 +416,29 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) ...(forceRefresh ? { forceRefresh: true } : {}), }); } catch { - markPendingForceRefreshMiss(key); + markPendingForceRefreshMiss(forceKey); return null; } - const quota = convertUsageToQuotaInfo(usage); + const quota = convertUsageToQuotaInfo(usage, { provider, requestedModel }); if (!quota) { - markPendingForceRefreshMiss(key); + markPendingForceRefreshMiss(forceKey); return null; } // Concurrent 429 re-stamped a still-live flag — do not recache the // pre-429 snapshot. A vanished or expired stamp is not a 429. - if (isConcurrentForceRefresh(key, refreshStamp)) { + if (isConcurrentForceRefresh(forceKey, refreshStamp)) { return quota; } - pendingForceRefresh.delete(key); - pendingForceRefreshMiss.delete(key); + pendingForceRefresh.delete(forceKey); + pendingForceRefreshMiss.delete(forceKey); - // Refresh the static window catalog so the dashboard can render the right - // modal inputs without waiting for the user to open the page. - registerQuotaWindows(provider, Object.keys(quota.windows || {})); + // Refresh the static window catalog from the unscoped usage payload so a + // family-scoped request cannot hide sibling-family dashboard controls. + const unscopedQuota = convertUsageToQuotaInfo(usage, { provider }); + registerQuotaWindows(provider, Object.keys(unscopedQuota?.windows || quota.windows || {})); cache.set(key, { quota, fetchedAt: Date.now() }); return quota; @@ -373,13 +450,16 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) * fresh data instead of a 60s stale window. */ export function invalidateGenericQuotaCache(provider: string, connectionId: string): void { - const key = cacheKey(provider, connectionId); - cache.delete(key); + const forceKey = connectionKey(provider, connectionId); + const prefix = `${forceKey}::`; + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); + } // Next fetch must bypass provider-inner usage caches (agy retrieveUserQuota / // weekly are 60s–5min). Without this, dropping the 60s wrapper recaches stale. // TTL matches those inner caches: after 5min the flag is a no-op. - pendingForceRefresh.set(key, Date.now()); - pendingForceRefreshMiss.delete(key); + pendingForceRefresh.set(forceKey, Date.now()); + pendingForceRefreshMiss.delete(forceKey); } /** @@ -415,3 +495,15 @@ export function registerGenericQuotaFetchers(): void { registerQuotaFetcher(provider, fetchGenericQuota); } } + +export const __testing = { + setUsageFetcher(fetcher: UsageFetcher): void { + usageFetcherOverride = fetcher; + }, + resetUsageFetcher(): void { + usageFetcherOverride = null; + }, + clearCache(): void { + cache.clear(); + }, +}; diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index dd0c7be367..990a5b9fe4 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -17,6 +17,8 @@ const { clearAllStickyBindings } = const { invalidateCodexQuotaCache, registerCodexConnection, registerCodexQuotaFetcher } = await import("../../open-sse/services/codexQuotaFetcher.ts"); const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); +const { getQuotaScopedModelForProvider } = + await import("../../open-sse/services/antigravityQuotaFamily.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const { recordComboRequest } = await import("../../open-sse/services/comboMetrics.ts"); @@ -434,6 +436,14 @@ test("reset-aware strategy avoids accounts near 5h exhaustion", async (t) => { assert.equal(await selectedConnectionFor(combo), healthy5h.id); }); +test("Antigravity aliases share one family-scoped cache key", () => { + assert.equal(getQuotaScopedModelForProvider("agy", "gemini-3.7-flash-high"), "family:gemini"); + assert.equal( + getQuotaScopedModelForProvider("antigravity", "gemini-3.7-flash-high"), + "family:gemini" + ); +}); + test("reset-aware strategy rotates similar scores with round-robin tie breaking", async () => { const provider = `tie-provider-${randomUUID()}`; const first = `first-${randomUUID()}`; diff --git a/tests/unit/reset-aware-request-scope-12600.test.ts b/tests/unit/reset-aware-request-scope-12600.test.ts new file mode 100644 index 0000000000..d035175c2b --- /dev/null +++ b/tests/unit/reset-aware-request-scope-12600.test.ts @@ -0,0 +1,234 @@ +import test, { afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const genericModule = await import("../../open-sse/services/genericQuotaFetcher.ts"); +const scoringModule = await import("../../open-sse/services/combo/quotaScoring.ts"); +const familyModule = await import("../../open-sse/services/antigravityQuotaFamily.ts"); +const preflightModule = await import("../../open-sse/services/quotaPreflight.ts"); + +const { convertUsageToQuotaInfo, fetchGenericQuota, invalidateGenericQuotaCache } = genericModule; +const { scoreResetAwareQuota, resolveResetAwareConfig } = scoringModule; +const { getQuotaFetchScope } = familyModule; +const { getQuotaWindows } = preflightModule; + +const resetAt5h = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); +const resetAt7d = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(); + +const usage = { + quotas: { + "gemini-3.7-flash-high": { + used: 30, + total: 1000, + remainingPercentage: 97, + resetAt: resetAt5h, + }, + "claude-opus-4-6-thinking": { + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: resetAt7d, + }, + "gpt-oss-120b-medium": { + used: 900, + total: 1000, + remainingPercentage: 10, + resetAt: resetAt5h, + }, + gemini_weekly: { + used: 10, + total: 1000, + remainingPercentage: 99, + resetAt: resetAt7d, + }, + claude_gpt_weekly: { + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: resetAt7d, + }, + unrelated_weekly: { + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: resetAt7d, + }, + }, +}; + +test("reset-aware Gemini scoring ignores depleted Claude family quota", () => { + const quota = convertUsageToQuotaInfo(usage, { + provider: "agy", + requestedModel: "agy/gemini-3.7-flash-high", + }); + + assert.ok(quota); + assert.equal(quota.window5h?.percentUsed, 0.03); + assert.equal(quota.window7d?.percentUsed, 0.01); + assert.equal(quota.percentUsed, 0.03); + assert.equal(quota.limitReached, false); + assert.equal(quota.windows?.["claude-opus-4-6-thinking"], undefined); + assert.equal(quota.windows?.["gpt-oss-120b-medium"], undefined); + assert.equal(quota.windows?.claude_gpt_weekly, undefined); + assert.equal(quota.windows?.unrelated_weekly, undefined); + assert.ok(scoreResetAwareQuota(quota, resolveResetAwareConfig({})).score > 0.3); +}); + +test("opposite-family-only telemetry fails open as unknown", () => { + const gemini = convertUsageToQuotaInfo( + { quotas: { claude_gpt_weekly: usage.quotas.claude_gpt_weekly } }, + { provider: "agy", requestedModel: "gemini-3.7-flash-high" } + ); + const claude = convertUsageToQuotaInfo( + { quotas: { gemini_weekly: usage.quotas.gemini_weekly } }, + { provider: "antigravity", requestedModel: "claude-opus-4-6-thinking" } + ); + + assert.equal(gemini, null); + assert.equal(claude, null); + assert.equal(scoreResetAwareQuota(gemini, resolveResetAwareConfig({})).score, 0.5); + assert.equal(scoreResetAwareQuota(claude, resolveResetAwareConfig({})).score, 0.5); +}); + +test("Claude family excludes unknown weekly buckets", () => { + const quota = convertUsageToQuotaInfo( + { + quotas: { + "claude-opus-4-6-thinking": { + used: 100, + total: 1000, + remainingPercentage: 90, + resetAt: resetAt5h, + }, + claude_gpt_weekly: { + used: 100, + total: 1000, + remainingPercentage: 90, + resetAt: resetAt7d, + }, + unrelated_weekly: usage.quotas.unrelated_weekly, + }, + }, + { provider: "agy", requestedModel: "claude-opus-4-6-thinking" } + ); + + assert.ok(quota); + assert.equal(quota.limitReached, false); + assert.equal(quota.windows?.unrelated_weekly, undefined); + assert.equal(quota.window7d?.percentUsed, 0.1); +}); + +test("unscoped provider-limits conversion retains conservative global windows", () => { + const quota = convertUsageToQuotaInfo(usage); + + assert.ok(quota); + assert.equal(quota.window5h?.percentUsed, 1); + assert.equal(quota.window7d?.percentUsed, 1); + assert.equal(quota.limitReached, true); +}); + +test("reset-aware fetch scope is family-wide for Antigravity and * otherwise", () => { + assert.equal(getQuotaFetchScope("agy", "gemini-3.7-flash-high"), "family:gemini"); + assert.equal(getQuotaFetchScope("antigravity", "claude-opus-4-6-thinking"), "family:claude"); + assert.equal(getQuotaFetchScope("codex", "gpt-5"), "*"); +}); + +test("buildAutoCandidates uses the shared Antigravity fetch-scope helper", () => { + const combo = fs.readFileSync(new URL("../../open-sse/services/combo.ts", import.meta.url), "utf8"); + const strategies = fs.readFileSync( + new URL("../../open-sse/services/combo/quotaStrategies.ts", import.meta.url), + "utf8" + ); + + assert.match(combo, /getQuotaFetchScope\(/); + assert.doesNotMatch( + combo, + /provider === "antigravity" \|\| provider === "agy"\s*\n\s*\? getQuotaScopedModelForProvider/ + ); + assert.match(strategies, /getQuotaFetchScope\(/); + assert.doesNotMatch(strategies, /function getQuotaFetchScope/); +}); + +afterEach(() => { + genericModule.__testing?.resetUsageFetcher?.(); + genericModule.__testing?.clearCache?.(); +}); + +test("fetchGenericQuota scopes Gemini windows and still catalogs sibling families", async () => { + let fetches = 0; + genericModule.__testing.setUsageFetcher(async () => { + fetches += 1; + return usage; + }); + + const quota = await fetchGenericQuota("conn-gemini", { + provider: "agy", + requestedModel: "agy/gemini-3.7-flash-high", + }); + + assert.ok(quota); + assert.equal(quota.window5h?.percentUsed, 0.03); + assert.equal(quota.limitReached, false); + assert.equal(quota.windows?.claude_gpt_weekly, undefined); + assert.equal(quota.windows?.["claude-opus-4-6-thinking"], undefined); + const windows = getQuotaWindows("agy"); + assert.equal(windows.includes("claude_gpt_weekly"), true); + assert.equal(windows.includes("gemini_weekly"), true); + assert.equal(fetches, 1); +}); + +test("invalidateGenericQuotaCache clears every family-scoped entry for a connection", async () => { + let fetches = 0; + genericModule.__testing.setUsageFetcher(async () => { + fetches += 1; + return usage; + }); + + const connectionId = "conn-both-families"; + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "gemini-3.7-flash-high", + }); + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "claude-opus-4-6-thinking", + }); + assert.equal(fetches, 2); + + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "gemini-3.7-flash-high", + }); + assert.equal(fetches, 2); + + invalidateGenericQuotaCache("agy", connectionId); + + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "gemini-3.7-flash-high", + }); + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "claude-opus-4-6-thinking", + }); + assert.equal(fetches, 4); +}); + +test("non-Antigravity generic quota cache stays per connection, not per model", async () => { + let fetches = 0; + genericModule.__testing.setUsageFetcher(async () => { + fetches += 1; + return usage; + }); + + const connectionId = "conn-kimi"; + await fetchGenericQuota(connectionId, { + provider: "kimi", + requestedModel: "kimi-k2.5", + }); + await fetchGenericQuota(connectionId, { + provider: "kimi", + requestedModel: "kimi-k2.7", + }); + assert.equal(fetches, 1); +});