Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog.d/features/14533-expiry-first-account-rotation.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions open-sse/services/combo/quotaScoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<typeof resolveExpiryFirstConfig>,
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/routingStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const ACCOUNT_FALLBACK_STRATEGY_VALUES = [
"random",
"least-used",
"cost-optimized",
"expiry-first",
"strict-random",
] as const;

Expand Down
6 changes: 5 additions & 1 deletion src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2095,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
Expand All @@ -2111,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
Expand Down
134 changes: 134 additions & 0 deletions src/sse/services/expiryFirstAccountSelection.ts
Original file line number Diff line number Diff line change
@@ -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<string, { remainingPercentage?: number; resetAt?: string | null }>;
}

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: { <window>: { remainingPercentage, resetAt } } }`
* (percent REMAINING, 0-100); the scorers read `{ windows: { <window>:
* { 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<string, unknown> | null {
const quotas = (getQuotaCache(connectionId) as QuotaCacheView | null)?.quotas;
if (!quotas) return null;

const windows: Record<string, { percentUsed: number; resetAt: string | null }> = {};
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 ExpiryFirstCandidate>(
connections: readonly T[],
settings: Record<string, unknown> | null,
resolveQuotaView: (connectionId: string) => Record<string, unknown> | 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<T extends ExpiryFirstCandidate>(
orderedConnections: readonly T[],
settings: unknown
): T {
return (
selectExpiryFirstConnection(
orderedConnections,
settings as Record<string, unknown> | null,
buildConnectionQuotaWindowsView
) ?? orderedConnections[0]
);
}
Loading