mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
fix(combo): keep Antigravity Gemini usable when Claude weekly is empty (#12637)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem. Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo. Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18. Obrigado, @HouMinXi.
This commit is contained in:
1
changelog.d/fixes/reset-aware-model-family.md
Normal file
1
changelog.d/fixes/reset-aware-model-family.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function resolveQuotaExhaustionCutoffForTarget(
|
||||
const quota = await fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId,
|
||||
connection,
|
||||
connection: connection ? { ...connection, requestedModel } : connection,
|
||||
fetcher,
|
||||
config: resetWindowConfig,
|
||||
log,
|
||||
|
||||
@@ -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<TScore extends object>({
|
||||
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<unknown> {
|
||||
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();
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
type QuotaFetcher,
|
||||
type QuotaInfo,
|
||||
} from "./quotaPreflight.ts";
|
||||
import {
|
||||
getAntigravityQuotaFamily,
|
||||
getQuotaFetchScope,
|
||||
} from "./antigravityQuotaFamily.ts";
|
||||
|
||||
type UsageFetcher = (
|
||||
connection: Parameters<typeof getUsageForProvider>[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<string, CacheEntry>();
|
||||
|
||||
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<string, unknown>;
|
||||
if (
|
||||
@@ -235,31 +262,51 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null {
|
||||
}
|
||||
|
||||
const windows: Record<string, { percentUsed: number; resetAt: string | null }> = {};
|
||||
let worstPercent = 0;
|
||||
let worstResetAt: string | null = null;
|
||||
for (const [name, entry] of Object.entries(quotasObj as Record<string, unknown>)) {
|
||||
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<string, { percentUsed: number; resetAt: string | null }>
|
||||
windows: Record<string, { percentUsed: number; resetAt: string | null }>,
|
||||
context: UsageToQuotaContext
|
||||
): Record<string, { percentUsed: number; resetAt: string | null }> {
|
||||
const normalized: Record<string, { percentUsed: number; resetAt: string | null }> = {};
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
234
tests/unit/reset-aware-request-scope-12600.test.ts
Normal file
234
tests/unit/reset-aware-request-scope-12600.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user