From 2dfd9487814a5a5fd9abc250d516ccc8743b4b8f Mon Sep 17 00:00:00 2001 From: Tomas Fecko Date: Tue, 22 Sep 2026 18:03:32 +0200 Subject: [PATCH 1/3] feat(providers): add expiry-first account fallback strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider with several accounts could only rotate them with fill-first, round-robin, p2c, random, least-used or cost-optimized. None of these looks at when an account's quota window resets, so fill-first drains the top-priority account while the rest sit full until their windows roll over and the unspent quota is simply lost. expiry-first ranks each account by how much quota it must spend per hour to avoid wasting its leftover at the next reset: score = usable / hoursUntilNearestReset usable is the tightest window's remaining fraction, because nested windows (a session cap inside a weekly one) decrement together and an account can never spend more than its most constrained window allows. The deadline is the nearest reset, since that is when the first tranche is lost. An exhausted account scores zero, so it never wins on a near reset alone — which is why plain earliest-deadline-first is the wrong rule here. Measured on a four-account Codex pool: 26% left, resets in 145h -> 0.0018 /h 88% left, resets in 145h -> 0.0061 /h 1% left, resets in 8h -> excluded, exhausted 88% left, resets in 69h -> 0.0127 /h <- selected fill-first picks the first of those and lets the last one's 88% expire. Deliberately separate from reset-aware rather than reusing it: that scorer ranks mostly on leftover and adds resetUrgency * (1 - remaining), a recovery signal favouring a nearly empty account about to refresh. Its urgency term is relative to the nominal window length and saturates to zero outside it, so it scores the two 88% accounts above identically (0.341725) and cannot separate a 69h reset from a 145h one. Session stickiness and prompt-cache affinity are untouched; the strategy only orders accounts that the existing quota and rate-limit filters already allowed. Accounts within expiryFirstTieBandPercent of the leader rotate least-recently-used, so equivalent accounts still share load instead of pinning. The ordering is a pure exported function with an injected quota lookup, so it is covered without a database or any upstream quota call. --- .../14381-expiry-first-account-rotation.md | 10 ++ open-sse/services/combo/quotaScoring.ts | 82 ++++++++++ .../components/ProviderAccountRoutingCard.tsx | 4 +- src/shared/constants/routingStrategies.ts | 1 + src/sse/services/auth.ts | 110 +++++++++++++ .../expiry-first-account-rotation.test.ts | 151 ++++++++++++++++++ 6 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/14381-expiry-first-account-rotation.md create mode 100644 tests/unit/expiry-first-account-rotation.test.ts diff --git a/changelog.d/features/14381-expiry-first-account-rotation.md b/changelog.d/features/14381-expiry-first-account-rotation.md new file mode 100644 index 00000000000..260d83167c7 --- /dev/null +++ b/changelog.d/features/14381-expiry-first-account-rotation.md @@ -0,0 +1,10 @@ +- **feat(providers):** add `expiry-first`, a per-provider account fallback strategy that spends the + quota closest to being lost. It ranks each account by how much it must burn per hour to avoid + wasting its leftover at the next reset (`usable / hoursUntilNearestReset`), so a full account + whose window closes soon outranks an equally full one that holds for days, while a nearly empty + account never wins on its near reset alone. `fill-first` drains the top-priority account and lets + the rest roll over unspent; on a four-account Codex pool that left an 88% account untouched + through a reset. Distinct from `reset-aware`, whose `resetUrgency * (1 - remaining)` term is a + recovery signal and saturates to zero outside the nominal window length. Session stickiness and + prompt-cache affinity are untouched; accounts within `expiryFirstTieBandPercent` rotate + least-recently-used. diff --git a/open-sse/services/combo/quotaScoring.ts b/open-sse/services/combo/quotaScoring.ts index d214d53e7da..1b415f490cb 100644 --- a/open-sse/services/combo/quotaScoring.ts +++ b/open-sse/services/combo/quotaScoring.ts @@ -366,6 +366,88 @@ export function scoreResetAwareQuota( return { score }; } +const EXPIRY_FIRST_DEFAULTS = { + tieBandPercent: 5, + minHours: 0.25, + exhaustedFloorPercent: 1, +}; + +export function resolveExpiryFirstConfig(config: Record | null | undefined) { + const minHours = finiteNumberOrNull(config?.expiryFirstMinHours); + return { + tieBand: + getPercentConfig(config?.expiryFirstTieBandPercent, EXPIRY_FIRST_DEFAULTS.tieBandPercent) / + 100, + minHours: minHours !== null && minHours > 0 ? minHours : EXPIRY_FIRST_DEFAULTS.minHours, + exhaustedFloor: + getPercentConfig( + config?.expiryFirstExhaustedFloorPercent, + EXPIRY_FIRST_DEFAULTS.exhaustedFloorPercent + ) / 100, + }; +} + +/** + * Scores an account for `expiry-first`: how much quota it must spend PER HOUR to + * avoid losing it at the next reset. Higher score = more urgent to spend here. + * + * score = usable / hoursUntilNearestReset + * + * `usable` is the tightest window's remaining fraction, because nested windows + * (a 5h session inside a weekly cap) all decrement together and an account can + * never spend more than its most constrained window allows. The deadline is the + * NEAREST reset for the same reason: that is when the first tranche is lost. + * + * Deliberately different from `scoreResetAwareQuota`, which ranks mostly on + * leftover and adds `resetUrgency * (1 - remaining)` — a RECOVERY signal that + * favours a nearly empty account about to refresh. That answers "who will be + * useful soon"; this answers "whose quota is about to be thrown away", and for + * two accounts holding equal quota it is the one resetting sooner. Its urgency + * term also saturates to zero outside the nominal window length, so it cannot + * separate a reset 69h away from one 145h away at all. + * + * Returns 0 for an exhausted account so it is never preferred, and falls back to + * plain leftover when no window reports a reset time. + */ +export function scoreExpiryFirstQuota( + quota: unknown, + config: ReturnType, + nowMs: number = Date.now() +): { score: number } { + if (!quota || !isRecord(quota)) return { score: 0 }; + if (quota.limitReached === true) return { score: 0 }; + + const windows: QuotaWindowSnapshot[] = []; + for (const windowName of RESET_WINDOW_NAMES) { + const window = resolveQuotaWindowByName(quota, windowName); + if (window) windows.push(window); + } + if (windows.length === 0) { + for (const { window } of getQuotaWindowEntries(quota)) windows.push(window); + } + if (windows.length === 0) return { score: 0 }; + + let usable = 1; + let msUntilReset = Number.POSITIVE_INFINITY; + for (const window of windows) { + usable = Math.min(usable, clamp01(1 - (window.percentUsed ?? 0.5))); + const resetMs = parseResetTimeMs(window.resetAt); + if (Number.isFinite(resetMs)) msUntilReset = Math.min(msUntilReset, resetMs - nowMs); + } + + if (usable <= config.exhaustedFloor) return { score: 0 }; + // No reset telemetry at all: the deadline half is unknowable, so rank on + // leftover rather than inventing a deadline. Still ordered below any account + // that does report one and is under pressure. + if (!Number.isFinite(msUntilReset)) return { score: usable }; + + // A non-positive delta means the snapshot predates the reset it describes. + // Clamping to minHours treats it as maximally urgent, which is the safe side: + // a freshly reset window is full, and re-reading it costs nothing. + const hours = Math.max(config.minHours, msUntilReset / (60 * 60 * 1000)); + return { score: usable / hours }; +} + export function getResetAwareRemainingPercent(quota: unknown): number { if (!quota || !isRecord(quota)) return 100; if (quota.limitReached === true) return 0; diff --git a/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx index a1ced6625d3..2572d0633e0 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx @@ -16,7 +16,9 @@ type Props = { }; const STRATEGY_OPTIONS = ACCOUNT_FALLBACK_STRATEGY_VALUES.filter((v) => - ["fill-first", "round-robin", "priority", "p2c", "random", "least-used"].includes(v) + ["fill-first", "round-robin", "priority", "p2c", "random", "least-used", "expiry-first"].includes( + v + ) ); function clampProviderStickyLimit(raw: string): number { diff --git a/src/shared/constants/routingStrategies.ts b/src/shared/constants/routingStrategies.ts index d469926adbf..d6825b149c4 100644 --- a/src/shared/constants/routingStrategies.ts +++ b/src/shared/constants/routingStrategies.ts @@ -59,6 +59,7 @@ export const ACCOUNT_FALLBACK_STRATEGY_VALUES = [ "random", "least-used", "cost-optimized", + "expiry-first", "strict-random", ] as const; diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 5af35dcf0e3..683f156defa 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -54,6 +54,10 @@ import { getQuotaScopeLabelForProvider, isAntigravityQuotaProvider, } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { + resolveExpiryFirstConfig, + scoreExpiryFirstQuota, +} from "@omniroute/open-sse/services/combo/quotaScoring.ts"; import { rehydrateAntigravityFamilyLocksForConnections, persistAntigravityFamilyCooldownIfQuota, @@ -485,6 +489,93 @@ function collectPolicyQuotaHeadroomPercentages( return percentages; } +/** + * Adapts the per-connection quota cache to the shape the combo quota scorers read. + * The cache stores `{ quotas: { : { remainingPercentage, resetAt } } }` + * (percent REMAINING, 0-100); the scorers read `{ windows: { : + * { percentUsed, resetAt } } }` (fraction USED, 0-1). Converting rather than + * re-fetching keeps account selection free of any upstream quota call. + * + * Returns null when no window carries a usable percentage, so the caller can + * tell "no telemetry" apart from "telemetry says empty". + */ +export function buildConnectionQuotaWindowsView( + connectionId: string +): Record | null { + const quotas = (getQuotaCache(connectionId) as QuotaCacheView | null)?.quotas; + if (!quotas) return null; + + const windows: Record = {}; + for (const [windowName, quota] of Object.entries(quotas)) { + const remainingPercent = toNumber(quota?.remainingPercentage, Number.NaN); + if (!Number.isFinite(remainingPercent)) continue; + windows[windowName.toLowerCase()] = { + percentUsed: Math.min(1, Math.max(0, 1 - remainingPercent / 100)), + resetAt: toStringOrNull(quota?.resetAt), + }; + } + + return Object.keys(windows).length > 0 ? { windows } : null; +} + +/** + * Orders accounts for `expiry-first` and returns the winner. + * + * Pure and quota-source agnostic (`resolveQuotaView` is injected) so the ranking + * is testable without a database or an upstream call. + * + * Accounts within `expiryFirstTieBandPercent` of the leader — compared + * RELATIVELY, since the score is a rate and not a 0-1 value — are equivalent and + * rotate least-recently-used. Without the band a rounding difference would pin + * every request to one account and the strategy would degenerate into fill-first + * for a pool of equivalent accounts. + */ +export function selectExpiryFirstConnection< + T extends { + id: string; + priority?: number | null; + backoffLevel?: number | null; + lastUsedAt?: string | null; + }, +>( + connections: readonly T[], + settings: Record | null, + resolveQuotaView: (connectionId: string) => Record | null, + nowMs: number = Date.now() +): T | null { + if (connections.length === 0) return null; + + const config = resolveExpiryFirstConfig(settings); + const scored = connections.map((candidate) => ({ + candidate, + score: scoreExpiryFirstQuota(resolveQuotaView(candidate.id), config, nowMs).score, + })); + scored.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; // most urgent first + return (a.candidate.priority || 999) - (b.candidate.priority || 999); + }); + + const bestScore = scored[0].score; + // Every account scored zero (no telemetry, or all exhausted): fall through to + // the priority order the pool arrived in rather than picking arbitrarily. + if (bestScore <= 0) return connections[0]; + + const tied = scored.filter((entry) => (bestScore - entry.score) / bestScore <= config.tieBand); + if (tied.length <= 1) return scored[0].candidate; + + tied.sort((a, b) => { + const aBackoff = a.candidate.backoffLevel || 0; + const bBackoff = b.candidate.backoffLevel || 0; + if (aBackoff !== bBackoff) return aBackoff - bBackoff; + if (!a.candidate.lastUsedAt && !b.candidate.lastUsedAt) + return (a.candidate.priority || 999) - (b.candidate.priority || 999); + if (!a.candidate.lastUsedAt) return -1; + if (!b.candidate.lastUsedAt) return 1; + return new Date(a.candidate.lastUsedAt).getTime() - new Date(b.candidate.lastUsedAt).getTime(); + }); + return tied[0].candidate; +} + function collectCachedQuotaHeadroomPercentages( provider: string, connection: ProviderConnectionView, @@ -2127,6 +2218,25 @@ export async function getProviderCredentials( (a, b) => (a.priority || 999) - (b.priority || 999) ); connection = sorted[0]; + } else if (strategy === "expiry-first") { + // Expiry-first: spend the quota that is closest to being lost. Ranks each + // account by how much it must burn PER HOUR to avoid wasting its leftover + // at the next reset, so a full account whose window closes soon outranks an + // equally full one that holds for days. fill-first drains the top-priority + // account and lets the rest roll over unspent; that lost quota is the whole + // reason this strategy exists. + connection = + selectExpiryFirstConnection( + orderedConnections, + settings as unknown as Record | null, + buildConnectionQuotaWindowsView + ) ?? orderedConnections[0]; + + // Commit lastUsedAt for the same reason least-used does (#10945): the tie + // band reads the field, so without writing it the rotation would stall. + const commit = planLastUsedCommit(connection, connectionsRaw, 1); + if (options.lease) commitSelectionSideEffects = commit; + else await commit(); } else if (strategy === "strict-random") { // Strict Random: shuffle deck — uses each account once before reshuffling const ids = orderedConnections.map((c) => c.id); diff --git a/tests/unit/expiry-first-account-rotation.test.ts b/tests/unit/expiry-first-account-rotation.test.ts new file mode 100644 index 00000000000..cb423057604 --- /dev/null +++ b/tests/unit/expiry-first-account-rotation.test.ts @@ -0,0 +1,151 @@ +/** + * `expiry-first`: a per-provider ACCOUNT fallback strategy that spends the quota + * closest to being lost. + * + * Why a new strategy rather than reusing `reset-aware`: they answer different + * questions. `scoreResetAwareQuota` ranks mostly on leftover and adds + * `resetUrgency * (1 - remaining)`, a RECOVERY signal that favours a nearly empty + * account about to refresh. Its urgency term is also relative to a nominal window + * length (5h session / 7d weekly) and saturates to zero outside it, so on the + * real four-account Codex pool below it scores the two 88% accounts IDENTICALLY + * (0.341725 each) and cannot separate a reset 69h away from one 145h away. + * + * The pool, measured 2026-09-22: + * + * priority 1 26% left, resets in ~145h -> 0.26 / 145 = 0.0018 /h + * priority 2 88% left, resets in ~145h -> 0.88 / 145 = 0.0061 /h + * priority 3 1% left, resets in ~8h -> exhausted, excluded + * priority 4 88% left, resets in ~69h -> 0.88 / 69.3 = 0.0127 /h + * + * fill-first picks priority 1 and lets priority 4's 88% expire. expiry-first + * picks priority 4. Priority 3 must NOT win despite the nearest reset: there is + * nothing left to spend, which is why ranking on the deadline alone (plain + * earliest-deadline-first) is the wrong rule. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { selectExpiryFirstConnection } from "@/sse/services/auth"; +import { + resolveExpiryFirstConfig, + scoreExpiryFirstQuota, +} from "@omniroute/open-sse/services/combo/quotaScoring.ts"; + +const HOUR = 60 * 60 * 1000; +const NOW = Date.UTC(2026, 8, 22, 11, 36); + +type Account = { + id: string; + priority: number; + lastUsedAt?: string | null; + backoffLevel?: number | null; +}; + +function view(remainingPercent: number, resetInHours: number) { + return { + windows: { + session: { + percentUsed: 1 - remainingPercent / 100, + resetAt: new Date(NOW + resetInHours * HOUR).toISOString(), + }, + }, + }; +} + +const POOL: Account[] = [ + { id: "prio1", priority: 1 }, + { id: "prio2", priority: 2 }, + { id: "prio3", priority: 3 }, + { id: "prio4", priority: 4 }, +]; +const VIEWS: Record> = { + prio1: view(26, 144.8), + prio2: view(88, 145.0), + prio3: view(1, 7.7), + prio4: view(88, 69.3), +}; +const pick = (pool: Account[], views: Record | null>) => + selectExpiryFirstConnection(pool, null, (id) => views[id] ?? null, NOW); + +describe("expiry-first account rotation", () => { + it("prefers the account whose quota is closest to expiring unused", () => { + assert.equal(pick(POOL, VIEWS)?.id, "prio4"); + }); + + it("separates two equally full accounts by reset time", () => { + const config = resolveExpiryFirstConfig(null); + const sooner = scoreExpiryFirstQuota(view(88, 69.3), config, NOW).score; + const later = scoreExpiryFirstQuota(view(88, 145.0), config, NOW).score; + assert.ok(sooner > later, `${sooner} should exceed ${later}`); + }); + + it("does not pick a nearly empty account just because it resets first", () => { + assert.notEqual(pick(POOL, VIEWS)?.id, "prio3"); + }); + + it("scores an exhausted account at zero so it is never preferred", () => { + const config = resolveExpiryFirstConfig(null); + assert.equal(scoreExpiryFirstQuota(view(0, 2), config, NOW).score, 0); + }); + + it("is bound by the tightest window, not the roomiest", () => { + const config = resolveExpiryFirstConfig(null); + const both = { + windows: { + session: { percentUsed: 1 - 0.9, resetAt: new Date(NOW + 100 * HOUR).toISOString() }, + weekly: { percentUsed: 1 - 0.2, resetAt: new Date(NOW + 10 * HOUR).toISOString() }, + }, + }; + // usable is the weekly 20%, deadline the weekly 10h -> 0.02/h, not 0.009/h. + assert.ok(Math.abs(scoreExpiryFirstQuota(both, config, NOW).score - 0.02) < 1e-9); + }); + + it("rotates accounts whose pressure ties, instead of pinning the first", () => { + const twins: Account[] = [ + { id: "a", priority: 1, lastUsedAt: new Date(NOW - 1 * HOUR).toISOString() }, + { id: "b", priority: 2, lastUsedAt: new Date(NOW - 9 * HOUR).toISOString() }, + ]; + const same = view(80, 40); + assert.equal( + selectExpiryFirstConnection(twins, null, () => same, NOW)?.id, + "b", + "least-recently-used account should win a tie" + ); + }); + + it("skips an account in backoff when pressure ties", () => { + const twins: Account[] = [ + { + id: "hurt", + priority: 1, + lastUsedAt: new Date(NOW - 9 * HOUR).toISOString(), + backoffLevel: 2, + }, + { + id: "ok", + priority: 2, + lastUsedAt: new Date(NOW - 1 * HOUR).toISOString(), + backoffLevel: 0, + }, + ]; + const same = view(80, 40); + assert.equal(selectExpiryFirstConnection(twins, null, () => same, NOW)?.id, "ok"); + }); + + it("falls back to the incoming priority order when no account reports quota", () => { + assert.equal(pick(POOL, {})?.id, "prio1"); + }); + + it("ranks on leftover when windows report no reset time", () => { + const config = resolveExpiryFirstConfig(null); + const noReset = { windows: { session: { percentUsed: 0.4, resetAt: null } } }; + assert.equal(scoreExpiryFirstQuota(noReset, config, NOW).score, 0.6); + }); + + it("returns null for an empty pool", () => { + assert.equal( + selectExpiryFirstConnection([], null, () => null, NOW), + null + ); + }); +}); From fd6d9d444ee63481e94c7ce716fc08bcfa4d523e Mon Sep 17 00:00:00 2001 From: Tomas Fecko Date: Thu, 24 Sep 2026 02:39:44 -0300 Subject: [PATCH 2/3] chore(providers): fix changelog fragment PR number for expiry-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed changelog.d/features/14381-expiry-first-account-rotation.md to 14533-expiry-first-account-rotation.md — 14381 is an unrelated open issue (config-security CLI relay slice); the correct PR number is 14533. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- ...account-rotation.md => 14533-expiry-first-account-rotation.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/features/{14381-expiry-first-account-rotation.md => 14533-expiry-first-account-rotation.md} (100%) diff --git a/changelog.d/features/14381-expiry-first-account-rotation.md b/changelog.d/features/14533-expiry-first-account-rotation.md similarity index 100% rename from changelog.d/features/14381-expiry-first-account-rotation.md rename to changelog.d/features/14533-expiry-first-account-rotation.md From 3b74ba81b3a7944ca6a1aaa0d302b7049c75042c Mon Sep 17 00:00:00 2001 From: Tomas Fecko Date: Thu, 24 Sep 2026 11:38:35 -0300 Subject: [PATCH 3/3] refactor(providers): extract expiry-first account selection into a leaf module Move buildConnectionQuotaWindowsView and selectExpiryFirstConnection out of the frozen src/sse/services/auth.ts into src/sse/services/expiryFirstAccountSelection.ts, and route the expiry-first strategy through the least-used branch so it shares the lastUsedAt commit. auth.ts now carries only the import and a two-line dispatch; selection behavior is unchanged. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- src/sse/services/auth.ts | 116 +-------------- .../services/expiryFirstAccountSelection.ts | 134 ++++++++++++++++++ .../expiry-first-account-rotation.test.ts | 2 +- 3 files changed, 140 insertions(+), 112 deletions(-) create mode 100644 src/sse/services/expiryFirstAccountSelection.ts diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 683f156defa..266e90ae107 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -4,6 +4,7 @@ import { hydrateConnectionProviderSpecificData } from "./compatibleNodeBaseUrl.t import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { describeUpstreamFailure } from "@/shared/utils/upstreamError"; import { buildAllExpiredCredentials } from "./authExpiredCredentials.ts"; +import { pickExpiryFirstConnection } from "./expiryFirstAccountSelection.ts"; import { getCachedRawProviderConnections, getCachedProviderNodes, @@ -54,10 +55,6 @@ import { getQuotaScopeLabelForProvider, isAntigravityQuotaProvider, } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; -import { - resolveExpiryFirstConfig, - scoreExpiryFirstQuota, -} from "@omniroute/open-sse/services/combo/quotaScoring.ts"; import { rehydrateAntigravityFamilyLocksForConnections, persistAntigravityFamilyCooldownIfQuota, @@ -489,93 +486,6 @@ function collectPolicyQuotaHeadroomPercentages( return percentages; } -/** - * Adapts the per-connection quota cache to the shape the combo quota scorers read. - * The cache stores `{ quotas: { : { remainingPercentage, resetAt } } }` - * (percent REMAINING, 0-100); the scorers read `{ windows: { : - * { percentUsed, resetAt } } }` (fraction USED, 0-1). Converting rather than - * re-fetching keeps account selection free of any upstream quota call. - * - * Returns null when no window carries a usable percentage, so the caller can - * tell "no telemetry" apart from "telemetry says empty". - */ -export function buildConnectionQuotaWindowsView( - connectionId: string -): Record | null { - const quotas = (getQuotaCache(connectionId) as QuotaCacheView | null)?.quotas; - if (!quotas) return null; - - const windows: Record = {}; - for (const [windowName, quota] of Object.entries(quotas)) { - const remainingPercent = toNumber(quota?.remainingPercentage, Number.NaN); - if (!Number.isFinite(remainingPercent)) continue; - windows[windowName.toLowerCase()] = { - percentUsed: Math.min(1, Math.max(0, 1 - remainingPercent / 100)), - resetAt: toStringOrNull(quota?.resetAt), - }; - } - - return Object.keys(windows).length > 0 ? { windows } : null; -} - -/** - * Orders accounts for `expiry-first` and returns the winner. - * - * Pure and quota-source agnostic (`resolveQuotaView` is injected) so the ranking - * is testable without a database or an upstream call. - * - * Accounts within `expiryFirstTieBandPercent` of the leader — compared - * RELATIVELY, since the score is a rate and not a 0-1 value — are equivalent and - * rotate least-recently-used. Without the band a rounding difference would pin - * every request to one account and the strategy would degenerate into fill-first - * for a pool of equivalent accounts. - */ -export function selectExpiryFirstConnection< - T extends { - id: string; - priority?: number | null; - backoffLevel?: number | null; - lastUsedAt?: string | null; - }, ->( - connections: readonly T[], - settings: Record | null, - resolveQuotaView: (connectionId: string) => Record | null, - nowMs: number = Date.now() -): T | null { - if (connections.length === 0) return null; - - const config = resolveExpiryFirstConfig(settings); - const scored = connections.map((candidate) => ({ - candidate, - score: scoreExpiryFirstQuota(resolveQuotaView(candidate.id), config, nowMs).score, - })); - scored.sort((a, b) => { - if (a.score !== b.score) return b.score - a.score; // most urgent first - return (a.candidate.priority || 999) - (b.candidate.priority || 999); - }); - - const bestScore = scored[0].score; - // Every account scored zero (no telemetry, or all exhausted): fall through to - // the priority order the pool arrived in rather than picking arbitrarily. - if (bestScore <= 0) return connections[0]; - - const tied = scored.filter((entry) => (bestScore - entry.score) / bestScore <= config.tieBand); - if (tied.length <= 1) return scored[0].candidate; - - tied.sort((a, b) => { - const aBackoff = a.candidate.backoffLevel || 0; - const bBackoff = b.candidate.backoffLevel || 0; - if (aBackoff !== bBackoff) return aBackoff - bBackoff; - if (!a.candidate.lastUsedAt && !b.candidate.lastUsedAt) - return (a.candidate.priority || 999) - (b.candidate.priority || 999); - if (!a.candidate.lastUsedAt) return -1; - if (!b.candidate.lastUsedAt) return 1; - return new Date(a.candidate.lastUsedAt).getTime() - new Date(b.candidate.lastUsedAt).getTime(); - }); - return tied[0].candidate; -} - function collectCachedQuotaHeadroomPercentages( provider: string, connection: ProviderConnectionView, @@ -2186,7 +2096,7 @@ export async function getProviderCredentials( const idx = parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % orderedConnections.length; connection = orderedConnections[idx]; - } else if (strategy === "least-used") { + } else if (strategy === "least-used" || strategy === "expiry-first") { // Least Used: pick the one with oldest lastUsedAt. // #12279: prefer accounts without backoff first, the same tie-break the // round-robin fallback branch applies. Without it the oldest lastUsedAt @@ -2202,6 +2112,9 @@ export async function getProviderCredentials( return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime(); }); connection = sorted[0]; + // expiry-first (#14533) ranks by the quota closest to being lost; see its leaf module. + if (strategy === "expiry-first") + connection = pickExpiryFirstConnection(orderedConnections, settings); // Record the use (#10945). This strategy sorts on the very field it was // not writing, so on a pool where every lastUsedAt is null the tie-break // fell through to `priority` and returned the SAME connection on every @@ -2218,25 +2131,6 @@ export async function getProviderCredentials( (a, b) => (a.priority || 999) - (b.priority || 999) ); connection = sorted[0]; - } else if (strategy === "expiry-first") { - // Expiry-first: spend the quota that is closest to being lost. Ranks each - // account by how much it must burn PER HOUR to avoid wasting its leftover - // at the next reset, so a full account whose window closes soon outranks an - // equally full one that holds for days. fill-first drains the top-priority - // account and lets the rest roll over unspent; that lost quota is the whole - // reason this strategy exists. - connection = - selectExpiryFirstConnection( - orderedConnections, - settings as unknown as Record | null, - buildConnectionQuotaWindowsView - ) ?? orderedConnections[0]; - - // Commit lastUsedAt for the same reason least-used does (#10945): the tie - // band reads the field, so without writing it the rotation would stall. - const commit = planLastUsedCommit(connection, connectionsRaw, 1); - if (options.lease) commitSelectionSideEffects = commit; - else await commit(); } else if (strategy === "strict-random") { // Strict Random: shuffle deck — uses each account once before reshuffling const ids = orderedConnections.map((c) => c.id); diff --git a/src/sse/services/expiryFirstAccountSelection.ts b/src/sse/services/expiryFirstAccountSelection.ts new file mode 100644 index 00000000000..caaee8546b1 --- /dev/null +++ b/src/sse/services/expiryFirstAccountSelection.ts @@ -0,0 +1,134 @@ +/** + * `expiry-first` per-provider ACCOUNT fallback strategy (#14533), extracted from + * auth.ts as a leaf so the frozen god-file `auth.ts` only carries the wiring. + * + * Spends the quota that is closest to being lost: each account is ranked by how + * much it must burn PER HOUR to avoid wasting its leftover at the next reset, so + * a full account whose window closes soon outranks an equally full one that + * holds for days. fill-first drains the top-priority account and lets the rest + * roll over unspent; that lost quota is the whole reason this strategy exists. + * + * This module never imports auth.ts (no cycle); it reads the same per-connection + * quota cache auth.ts reads and reuses the combo quota scorer. + */ + +import { getQuotaCache } from "@/domain/quotaCache"; +import { toNumber } from "@/shared/utils/numeric"; +import { + resolveExpiryFirstConfig, + scoreExpiryFirstQuota, +} from "@omniroute/open-sse/services/combo/quotaScoring.ts"; + +interface QuotaCacheView { + quotas?: Record; +} + +interface ExpiryFirstCandidate { + id: string; + priority?: number | null; + backoffLevel?: number | null; + lastUsedAt?: string | null; +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** + * Adapts the per-connection quota cache to the shape the combo quota scorers read. + * The cache stores `{ quotas: { : { remainingPercentage, resetAt } } }` + * (percent REMAINING, 0-100); the scorers read `{ windows: { : + * { percentUsed, resetAt } } }` (fraction USED, 0-1). Converting rather than + * re-fetching keeps account selection free of any upstream quota call. + * + * Returns null when no window carries a usable percentage, so the caller can + * tell "no telemetry" apart from "telemetry says empty". + */ +export function buildConnectionQuotaWindowsView( + connectionId: string +): Record | null { + const quotas = (getQuotaCache(connectionId) as QuotaCacheView | null)?.quotas; + if (!quotas) return null; + + const windows: Record = {}; + for (const [windowName, quota] of Object.entries(quotas)) { + const remainingPercent = toNumber(quota?.remainingPercentage, Number.NaN); + if (!Number.isFinite(remainingPercent)) continue; + windows[windowName.toLowerCase()] = { + percentUsed: Math.min(1, Math.max(0, 1 - remainingPercent / 100)), + resetAt: toStringOrNull(quota?.resetAt), + }; + } + + return Object.keys(windows).length > 0 ? { windows } : null; +} + +/** + * Orders accounts for `expiry-first` and returns the winner. + * + * Pure and quota-source agnostic (`resolveQuotaView` is injected) so the ranking + * is testable without a database or an upstream call. + * + * Accounts within `expiryFirstTieBandPercent` of the leader — compared + * RELATIVELY, since the score is a rate and not a 0-1 value — are equivalent and + * rotate least-recently-used. Without the band a rounding difference would pin + * every request to one account and the strategy would degenerate into fill-first + * for a pool of equivalent accounts. + */ +export function selectExpiryFirstConnection( + connections: readonly T[], + settings: Record | null, + resolveQuotaView: (connectionId: string) => Record | null, + nowMs: number = Date.now() +): T | null { + if (connections.length === 0) return null; + + const config = resolveExpiryFirstConfig(settings); + const scored = connections.map((candidate) => ({ + candidate, + score: scoreExpiryFirstQuota(resolveQuotaView(candidate.id), config, nowMs).score, + })); + scored.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; // most urgent first + return (a.candidate.priority || 999) - (b.candidate.priority || 999); + }); + + const bestScore = scored[0].score; + // Every account scored zero (no telemetry, or all exhausted): fall through to + // the priority order the pool arrived in rather than picking arbitrarily. + if (bestScore <= 0) return connections[0]; + + const tied = scored.filter((entry) => (bestScore - entry.score) / bestScore <= config.tieBand); + if (tied.length <= 1) return scored[0].candidate; + + tied.sort((a, b) => { + const aBackoff = a.candidate.backoffLevel || 0; + const bBackoff = b.candidate.backoffLevel || 0; + if (aBackoff !== bBackoff) return aBackoff - bBackoff; + if (!a.candidate.lastUsedAt && !b.candidate.lastUsedAt) + return (a.candidate.priority || 999) - (b.candidate.priority || 999); + if (!a.candidate.lastUsedAt) return -1; + if (!b.candidate.lastUsedAt) return 1; + return new Date(a.candidate.lastUsedAt).getTime() - new Date(b.candidate.lastUsedAt).getTime(); + }); + return tied[0].candidate; +} + +/** + * The `getProviderCredentials` entry point: ranks the ordered pool against the + * live quota cache and falls back to the first connection when the pool yields + * no winner. The caller commits `lastUsedAt` afterwards (same as least-used, + * #10945) because the tie band reads that field. + */ +export function pickExpiryFirstConnection( + orderedConnections: readonly T[], + settings: unknown +): T { + return ( + selectExpiryFirstConnection( + orderedConnections, + settings as Record | null, + buildConnectionQuotaWindowsView + ) ?? orderedConnections[0] + ); +} diff --git a/tests/unit/expiry-first-account-rotation.test.ts b/tests/unit/expiry-first-account-rotation.test.ts index cb423057604..1e4d0a6464a 100644 --- a/tests/unit/expiry-first-account-rotation.test.ts +++ b/tests/unit/expiry-first-account-rotation.test.ts @@ -25,7 +25,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { selectExpiryFirstConnection } from "@/sse/services/auth"; +import { selectExpiryFirstConnection } from "@/sse/services/expiryFirstAccountSelection"; import { resolveExpiryFirstConfig, scoreExpiryFirstQuota,