diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 8bd9efa5e..78dab9070 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1,7 +1,8 @@ /** * Shared combo (model combo) handling with fallback support * Supports: priority, weighted, round-robin, random, least-used, cost-optimized, - * strict-random, auto, fill-first, p2c, lkgp, context-optimized, and context-relay strategies + * reset-aware, strict-random, auto, fill-first, p2c, lkgp, context-optimized, + * and context-relay strategies */ import { @@ -86,6 +87,16 @@ const DEFAULT_MODEL_P95_MS = { "deepseek-chat": 2000, }; const MIN_HISTORY_SAMPLES = 10; +const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000; +const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const RESET_AWARE_REMAINING_WEIGHT = 0.55; +const RESET_AWARE_RESET_WEIGHT = 0.45; +const RESET_AWARE_DEFAULTS = { + sessionWeight: 0.35, + weeklyWeight: 0.65, + tieBandPercent: 5, + exhaustionGuardPercent: 10, +}; type ResolvedComboTarget = { kind: "model"; @@ -697,6 +708,237 @@ function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboNa return [targets[selectedIndex], ...targets.filter((_, index) => index !== selectedIndex)]; } +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value)); +} + +function finiteNumberOrNull(value: unknown): number | null { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; +} + +function getPercentConfig(value: unknown, fallback: number): number { + const numericValue = finiteNumberOrNull(value); + if (numericValue === null) return fallback; + return Math.max(0, Math.min(100, numericValue)); +} + +function getWeightConfig(value: unknown, fallback: number): number { + const numericValue = finiteNumberOrNull(value); + if (numericValue === null || numericValue < 0) return fallback; + return numericValue; +} + +function resolveResetAwareConfig(config: Record | null | undefined) { + const sessionWeight = getWeightConfig( + config?.resetAwareSessionWeight, + RESET_AWARE_DEFAULTS.sessionWeight + ); + const weeklyWeight = getWeightConfig( + config?.resetAwareWeeklyWeight, + RESET_AWARE_DEFAULTS.weeklyWeight + ); + const totalWeight = sessionWeight + weeklyWeight; + const normalizedSessionWeight = totalWeight > 0 ? sessionWeight / totalWeight : 0.35; + + return { + sessionWeight: normalizedSessionWeight, + weeklyWeight: 1 - normalizedSessionWeight, + tieBand: + getPercentConfig(config?.resetAwareTieBandPercent, RESET_AWARE_DEFAULTS.tieBandPercent) / 100, + exhaustionGuard: + getPercentConfig( + config?.resetAwareExhaustionGuardPercent, + RESET_AWARE_DEFAULTS.exhaustionGuardPercent + ) / 100, + }; +} + +function isCodexTarget(target: ResolvedComboTarget): boolean { + const provider = (target.providerId || target.provider || "").toLowerCase(); + return provider === "codex" || target.modelStr.toLowerCase().startsWith("codex/"); +} + +function getQuotaWindow( + quota: unknown, + key: "window5h" | "window7d" +): { percentUsed: number | null; resetAt: string | null } | null { + if (!isRecord(quota)) return null; + const window = quota[key]; + if (!isRecord(window)) return null; + const percentUsed = finiteNumberOrNull(window.percentUsed); + const resetAt = + typeof window.resetAt === "string" && window.resetAt.length > 0 ? window.resetAt : null; + return { percentUsed, resetAt }; +} + +function getResetUrgency(resetAt: string | null | undefined, windowMs: number): number { + if (!resetAt) return 0.5; + const resetTime = new Date(resetAt).getTime(); + if (!Number.isFinite(resetTime)) return 0.5; + const msUntilReset = resetTime - Date.now(); + if (msUntilReset <= 0) return 1; + return clamp01(1 - msUntilReset / windowMs); +} + +function scoreQuotaWindow( + remaining: number, + resetAt: string | null | undefined, + windowMs: number +): number { + return ( + RESET_AWARE_REMAINING_WEIGHT * clamp01(remaining) + + RESET_AWARE_RESET_WEIGHT * getResetUrgency(resetAt, windowMs) + ); +} + +function scoreResetAwareQuota(quota: unknown, config: ReturnType) { + if (!quota || !isRecord(quota)) return { score: 0.5 }; + if (quota.limitReached === true) return { score: -Infinity }; + + const overallPercentUsed = clamp01(finiteNumberOrNull(quota.percentUsed) ?? 0.5); + const sessionWindow = getQuotaWindow(quota, "window5h"); + const weeklyWindow = getQuotaWindow(quota, "window7d"); + const sessionRemaining = clamp01(1 - (sessionWindow?.percentUsed ?? overallPercentUsed)); + const weeklyRemaining = clamp01(1 - (weeklyWindow?.percentUsed ?? overallPercentUsed)); + const sessionScore = scoreQuotaWindow( + sessionRemaining, + sessionWindow?.resetAt, + RESET_AWARE_SESSION_WINDOW_MS + ); + const weeklyScore = scoreQuotaWindow( + weeklyRemaining, + weeklyWindow?.resetAt ?? (typeof quota.resetAt === "string" ? quota.resetAt : null), + RESET_AWARE_WEEKLY_WINDOW_MS + ); + let score = config.sessionWeight * sessionScore + config.weeklyWeight * weeklyScore; + + if (config.exhaustionGuard > 0 && sessionRemaining < config.exhaustionGuard) { + score *= Math.max(0.05, sessionRemaining / config.exhaustionGuard); + } + + return { score }; +} + +async function getCodexConnectionsForTarget( + target: ResolvedComboTarget, + connectionCache: Map>> +) { + if (!isCodexTarget(target)) return []; + const provider = target.providerId || target.provider; + if (!provider) return []; + if (!connectionCache.has(provider)) { + try { + const connections = await getProviderConnections({ provider, isActive: true }); + connectionCache.set( + provider, + Array.isArray(connections) ? (connections as Array>) : [] + ); + } catch { + connectionCache.set(provider, []); + } + } + return connectionCache.get(provider) || []; +} + +function getTargetConnectionIds( + target: ResolvedComboTarget, + connections: Array> +): string[] { + if (target.connectionId) return [target.connectionId]; + if (Array.isArray(target.allowedConnectionIds) && target.allowedConnectionIds.length > 0) { + return target.allowedConnectionIds.filter( + (connectionId): connectionId is string => + typeof connectionId === "string" && connectionId.trim().length > 0 + ); + } + return connections + .map((connection) => (typeof connection.id === "string" ? connection.id : null)) + .filter((connectionId): connectionId is string => !!connectionId); +} + +async function orderTargetsByResetAwareQuota( + targets: ResolvedComboTarget[], + comboName: string, + configSource: Record | null | undefined, + log: { warn?: (...args: unknown[]) => void } +) { + if (targets.length === 0) return targets; + + const config = resolveResetAwareConfig(configSource); + const connectionCache = new Map>>(); + const connectionById = new Map>(); + const expandedTargets: ResolvedComboTarget[] = []; + + for (const target of targets) { + const connections = await getCodexConnectionsForTarget(target, connectionCache); + for (const connection of connections) { + if (typeof connection.id === "string") connectionById.set(connection.id, connection); + } + + const connectionIds = getTargetConnectionIds(target, connections); + if (connectionIds.length === 0) { + expandedTargets.push(target); + continue; + } + + for (const connectionId of connectionIds) { + expandedTargets.push({ + ...target, + connectionId, + executionKey: + target.connectionId === connectionId + ? target.executionKey + : `${target.executionKey}@${connectionId}`, + }); + } + } + + const scoredTargets = await Promise.all( + expandedTargets.map(async (target, index) => { + let quota: unknown = null; + if (isCodexTarget(target) && target.connectionId) { + try { + quota = await fetchCodexQuota( + target.connectionId, + connectionById.get(target.connectionId) + ); + } catch (error) { + log.warn?.( + "COMBO", + `Reset-aware quota fetch failed for connection=${target.connectionId}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + const { score } = scoreResetAwareQuota(quota, config); + return { target, score, index }; + }) + ); + + scoredTargets.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.index - b.index; + }); + + const bestScore = scoredTargets[0]?.score ?? 0; + const tiedTargets = scoredTargets.filter((entry) => bestScore - entry.score <= config.tieBand); + let orderedTiedTargets = tiedTargets; + if (tiedTargets.length > 1) { + const key = `reset-aware:${comboName}`; + const counter = rrCounters.get(key) || 0; + rrCounters.set(key, counter + 1); + const startIndex = counter % tiedTargets.length; + orderedTiedTargets = [...tiedTargets.slice(startIndex), ...tiedTargets.slice(0, startIndex)]; + } + + const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey)); + return [ + ...orderedTiedTargets, + ...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)), + ].map((entry) => entry.target); +} + function toTextContent(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -1482,6 +1724,12 @@ export async function handleComboChat({ } else if (strategy === "cost-optimized") { orderedTargets = await sortTargetsByCost(orderedTargets); log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedTargets[0]?.modelStr})`); + } else if (strategy === "reset-aware") { + orderedTargets = await orderTargetsByResetAwareQuota(orderedTargets, combo.name, config, log); + log.info( + "COMBO", + `Reset-aware ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first` + ); } else if (strategy === "context-optimized") { orderedTargets = sortTargetsByContextSize(orderedTargets); log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`); diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index af464f1aa..fa383c824 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -17,6 +17,10 @@ const DEFAULT_COMBO_CONFIG = { maxMessagesForSummary: 30, maxComboDepth: 3, trackMetrics: true, + resetAwareSessionWeight: 0.35, + resetAwareWeeklyWeight: 0.65, + resetAwareTieBandPercent: 5, + resetAwareExhaustionGuardPercent: 10, }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 58c8b3ebc..1b121a168 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -64,11 +64,14 @@ const STRATEGY_OPTIONS = ROUTING_STRATEGIES.map((strategy) => ({ const STRATEGY_LABEL_FALLBACK = { "context-relay": "Context Relay", + "reset-aware": "Reset-Aware RR", }; const STRATEGY_DESC_FALLBACK = { "context-relay": "Priority-style routing with automatic context handoffs when account rotation happens.", + "reset-aware": + "Quota remaining and reset windows decide the order; similar scores rotate round-robin.", }; const STRATEGY_GUIDANCE_FALLBACK = { @@ -108,6 +111,11 @@ const STRATEGY_GUIDANCE_FALLBACK = { avoid: "Avoid when pricing data is missing or outdated.", example: "Example: Batch or background jobs where lower cost matters most.", }, + "reset-aware": { + when: "Use when multiple Codex accounts have different 5h and weekly reset windows.", + avoid: "Avoid when quota telemetry is unavailable for most accounts.", + example: "Example: Prefer a 60% weekly account resetting tomorrow over 80% that resets later.", + }, "fill-first": { when: "Use when you want to drain one provider's quota fully before moving to the next.", avoid: "Avoid when you need request-level load balancing across providers.", @@ -230,6 +238,15 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = { "Use for batch/background jobs where cost is the main KPI.", ], }, + "reset-aware": { + title: "Reset-aware account rotation", + description: "Balances remaining Codex quota against 5h and weekly reset timing.", + tips: [ + "Use explicit Codex account steps or account-tag routing.", + "Tune session vs weekly weights when short-term exhaustion is more risky.", + "Keep the tie band small so equivalent accounts still rotate fairly.", + ], + }, "fill-first": { title: "Quota drain strategy", description: "Exhausts one provider's quota before moving to the next in chain.", @@ -439,6 +456,7 @@ function getStrategyBadgeClass(strategy) { if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400"; if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400"; if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400"; + if (strategy === "reset-aware") return "bg-lime-500/15 text-lime-700 dark:text-lime-300"; if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400"; if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400"; return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 5daaaa212..98a59cab7 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1396,6 +1396,8 @@ "randomDesc": "Einheitliche Zufallsauswahl, dann Rückgriff auf verbleibende Modelle", "leastUsedDesc": "Wählt das Modell mit den wenigsten Anfragen aus und gleicht die Last über die Zeit aus", "costOptimizedDesc": "Leitet basierend auf dem Preis zuerst zum günstigsten Modell weiter", + "resetAware": "Reset-Aware RR", + "resetAwareDesc": "Gewichtet Restquote gegen 5h- und Wochen-Resets und rotiert ähnliche Scores per Round Robin", "strictRandom": "Strict Random", "strictRandomDesc": "Shuffle deck — uses each model once before reshuffling", "models": "Modelle", @@ -1447,6 +1449,11 @@ "avoid": "Preisdaten fehlen oder sind veraltet.", "example": "Hintergrund- oder Batch-Jobs, bei denen geringere Kosten bevorzugt werden." }, + "reset-aware": { + "when": "Du routest über mehrere Codex-Konten mit unterschiedlichen 5h- und Wochen-Reset-Fenstern.", + "avoid": "Für die meisten Konten fehlen Quota-Telemetriedaten.", + "example": "Bevorzuge ein Konto mit 60 % Wochen-Restquote und Reset morgen vor 80 % mit späterem Reset." + }, "strict-random": { "when": "Use when you want perfectly even spread — each model used once before repeating.", "avoid": "Avoid when models have different quality or latency and order matters.", @@ -1555,6 +1562,13 @@ "tip2": "Behalte einen Qualitäts-Fallback für schwierige Prompts.", "tip3": "Ideal für Batch/Hintergrundjobs, bei denen Kosten das Haupt-KPI sind." }, + "reset-aware": { + "title": "Reset-bewusste Kontorotation", + "description": "Gewichtet verbleibende Codex-Quote gegen 5h- und Wochen-Reset-Zeitpunkte.", + "tip1": "Nutze explizite Codex-Kontoschritte oder kontobasiertes Tag-Routing.", + "tip2": "Passe 5h- und Wochengewichtung an, wenn kurzfristige Erschöpfung riskant ist.", + "tip3": "Halte das Tie-Band klein, damit gleichwertige Konten fair rotieren." + }, "strict-random": { "title": "Shuffle deck distribution", "description": "Each model is used exactly once per cycle before reshuffling.", @@ -2906,6 +2920,8 @@ "leastUsedDesc": "Wählen Sie das zuletzt verwendete Konto aus", "costOpt": "Kosten Opt", "costOptDesc": "Bevorzugen Sie das günstigste verfügbare Konto", + "resetAware": "Reset-Aware RR", + "resetAwareDesc": "Bevorzugt Konten mit gesunder Restquote und näherem Reset", "strictRandom": "Strict Random", "strictRandomDesc": "Shuffle deck — uses each account once before reshuffling", "stickyLimit": "Sticky-Limit", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b2f4c692f..5bb7f3745 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1505,6 +1505,8 @@ "randomDesc": "Uniform random selection, then fallback to remaining models", "leastUsedDesc": "Picks the model with fewest requests, balancing load over time", "costOptimizedDesc": "Routes to the cheapest model first based on pricing", + "resetAware": "Reset-Aware RR", + "resetAwareDesc": "Balances remaining quota against 5h and weekly resets, then round-robins similar scores", "strictRandom": "Strict Random", "strictRandomDesc": "Shuffle deck — uses each model once before reshuffling", "models": "Models", @@ -1563,6 +1565,11 @@ "avoid": "Pricing data is missing or outdated.", "example": "Background or batch jobs where lower cost is preferred." }, + "reset-aware": { + "when": "You route across multiple Codex accounts with different 5h and weekly reset windows.", + "avoid": "Quota telemetry is unavailable for most accounts.", + "example": "Prefer a 60% weekly account resetting tomorrow over an 80% account resetting later." + }, "strict-random": { "when": "Use when you want perfectly even spread — each model used once before repeating.", "avoid": "Avoid when models have different quality or latency and order matters.", @@ -1735,6 +1742,13 @@ "tip2": "Keep a quality fallback for hard prompts.", "tip3": "Use for batch/background jobs where cost is the main KPI." }, + "reset-aware": { + "title": "Reset-aware account rotation", + "description": "Balances remaining Codex quota against 5h and weekly reset timing.", + "tip1": "Use explicit Codex account steps or account-tag routing.", + "tip2": "Tune session vs weekly weights when short-term exhaustion is more risky.", + "tip3": "Keep the tie band small so equivalent accounts still rotate fairly." + }, "strict-random": { "title": "Shuffle deck distribution", "description": "Each model is used exactly once per cycle before reshuffling.", @@ -3363,6 +3377,8 @@ "leastUsedDesc": "Pick least recently used account", "costOpt": "Cost Opt", "costOptDesc": "Prefer cheapest available account", + "resetAware": "Reset-Aware RR", + "resetAwareDesc": "Prefer accounts with healthy remaining quota and nearer resets", "strictRandom": "Strict Random", "strictRandomDesc": "Shuffle deck — uses each account once before reshuffling", "stickyLimit": "Sticky Limit", diff --git a/src/shared/constants/routingStrategies.ts b/src/shared/constants/routingStrategies.ts index 752683e7c..0a67a2cfe 100644 --- a/src/shared/constants/routingStrategies.ts +++ b/src/shared/constants/routingStrategies.ts @@ -8,6 +8,7 @@ export const ROUTING_STRATEGY_VALUES = [ "random", "least-used", "cost-optimized", + "reset-aware", "strict-random", "auto", "lkgp", @@ -123,6 +124,13 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [ settingsDescKey: "costOptDesc", icon: "savings", }, + { + value: "reset-aware", + labelKey: "resetAware", + combosDescKey: "resetAwareDesc", + settingsDescKey: "resetAwareDesc", + icon: "event_repeat", + }, { value: "strict-random", labelKey: "strictRandom", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 97a41d118..b94f1e439 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -395,6 +395,10 @@ const comboRuntimeConfigSchema = z explorationRate: z.number().min(0).max(1).optional(), routerStrategy: z.string().optional(), compositeTiers: compositeTiersSchema.optional(), + resetAwareSessionWeight: z.coerce.number().min(0).max(100).optional(), + resetAwareWeeklyWeight: z.coerce.number().min(0).max(100).optional(), + resetAwareTieBandPercent: z.coerce.number().min(0).max(100).optional(), + resetAwareExhaustionGuardPercent: z.coerce.number().min(0).max(100).optional(), }) .strict(); diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index 65d62c88d..f2343cdbb 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -1,15 +1,19 @@ import test, { after } from "node:test"; import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-strategies-")); const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_FETCH = globalThis.fetch; process.env.DATA_DIR = TEST_DATA_DIR; const dbCore = await import("../../src/lib/db/core.ts"); const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { invalidateCodexQuotaCache, registerCodexConnection } = + await import("../../open-sse/services/codexQuotaFetcher.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const { recordComboRequest } = await import("../../open-sse/services/comboMetrics.ts"); const { saveModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts"); @@ -22,6 +26,7 @@ after(() => { } else { process.env.DATA_DIR = ORIGINAL_DATA_DIR; } + globalThis.fetch = ORIGINAL_FETCH; }); const reqBodyNullContext = { @@ -93,8 +98,95 @@ async function selectedModelFor(combo: Record, body: Record) { + globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + const authorization = headers?.Authorization || headers?.authorization || ""; + const token = authorization.replace(/^Bearer\s+/i, ""); + const quota = quotasByToken[token]; + if (!quota) return Response.json({ error: "missing quota" }, { status: 404 }); + return Response.json(quota); + }; +} + +function resetAwareCombo( + name: string, + connections: Array<{ id: string; token: string }>, + config: Record = {} +) { + for (const connection of connections) { + invalidateCodexQuotaCache(connection.id); + registerCodexConnection(connection.id, { accessToken: connection.token }); + } + + return { + name, + strategy: "reset-aware", + config, + models: connections.map((connection, index) => ({ + kind: "model", + provider: "codex", + providerId: "codex", + model: "gpt-5", + connectionId: connection.id, + id: `${name}-${index}`, + })), + }; +} + +async function selectedConnectionFor(combo: Record) { + const calls: Array = []; + const response = await handleComboChat({ + body: reqBodyTextArray, + combo, + allCombos: [combo], + isModelAvailable: undefined, + relayOptions: undefined, + signal: undefined, + settings: {}, + log: makeLog(), + handleSingleModel: async ( + _body: unknown, + modelStr: string, + target?: { connectionId?: string | null } + ) => { + calls.push(target?.connectionId ?? null); + return okResponse(modelStr); + }, + }); + + assert.equal(response.status, 200); + assert.equal(calls.length > 0, true); + return calls[0]; +} + test("least-used strategy prefers the model with fewer recorded combo requests", async () => { - const name = `least-used-${crypto.randomUUID()}`; + const name = `least-used-${randomUUID()}`; const busyModel = "openai/gpt-4"; const idleModel = "openai/gpt-3.5-turbo"; const combo = await combosDb.createCombo({ @@ -121,7 +213,7 @@ test("context-optimized strategy prefers the largest context window", async () = }); const combo = await combosDb.createCombo({ - name: `context-optimized-${crypto.randomUUID()}`, + name: `context-optimized-${randomUUID()}`, strategy: "context-optimized", models: ["test-context/small", "test-context/large", "unknown/unknown"], }); @@ -131,7 +223,7 @@ test("context-optimized strategy prefers the largest context window", async () = test("auto strategy handles null and empty prompt edge cases without throwing", async () => { const combo = await combosDb.createCombo({ - name: `auto-${crypto.randomUUID()}`, + name: `auto-${randomUUID()}`, strategy: "auto", config: { auto: { explorationRate: 0 } }, models: ["openai/gpt-4"], @@ -146,3 +238,88 @@ test("auto strategy handles null and empty prompt edge cases without throwing", ); assert.equal(await selectedModelFor(combo, { model: combo.name, messages: [] }), "openai/gpt-4"); }); + +test("reset-aware strategy prefers lower weekly remaining quota when reset is much sooner", async () => { + const soon = { id: `soon-${randomUUID()}`, token: `token-soon-${randomUUID()}` }; + const later = { id: `later-${randomUUID()}`, token: `token-later-${randomUUID()}` }; + installCodexQuotaMock({ + [soon.token]: codexQuota({ + used5h: 10, + reset5hSeconds: 3600, + used7d: 40, + reset7dSeconds: 24 * 3600, + }), + [later.token]: codexQuota({ + used5h: 10, + reset5hSeconds: 3600, + used7d: 20, + reset7dSeconds: 5 * 24 * 3600, + }), + }); + + const combo = resetAwareCombo(`reset-aware-soon-${randomUUID()}`, [soon, later]); + + assert.equal(await selectedConnectionFor(combo), soon.id); +}); + +test("reset-aware strategy avoids accounts near 5h exhaustion", async () => { + const exhausted5h = { + id: `exhausted-${randomUUID()}`, + token: `token-exhausted-${randomUUID()}`, + }; + const healthy5h = { + id: `healthy-${randomUUID()}`, + token: `token-healthy-${randomUUID()}`, + }; + installCodexQuotaMock({ + [exhausted5h.token]: codexQuota({ + used5h: 98, + reset5hSeconds: 20 * 60, + used7d: 5, + reset7dSeconds: 24 * 3600, + }), + [healthy5h.token]: codexQuota({ + used5h: 20, + reset5hSeconds: 4 * 3600, + used7d: 50, + reset7dSeconds: 4 * 24 * 3600, + }), + }); + + const combo = resetAwareCombo(`reset-aware-guard-${randomUUID()}`, [exhausted5h, healthy5h]); + + assert.equal(await selectedConnectionFor(combo), healthy5h.id); +}); + +test("reset-aware strategy rotates similar scores with round-robin tie breaking", async () => { + const first = { id: `first-${randomUUID()}`, token: `token-first-${randomUUID()}` }; + const second = { + id: `second-${randomUUID()}`, + token: `token-second-${randomUUID()}`, + }; + installCodexQuotaMock({ + [first.token]: codexQuota({ + used5h: 50, + reset5hSeconds: 2 * 3600, + used7d: 50, + reset7dSeconds: 3 * 24 * 3600, + }), + [second.token]: codexQuota({ + used5h: 50, + reset5hSeconds: 2 * 3600, + used7d: 50, + reset7dSeconds: 3 * 24 * 3600, + }), + }); + + const combo = resetAwareCombo(`reset-aware-rr-${randomUUID()}`, [first, second]); + + assert.deepEqual( + [ + await selectedConnectionFor(combo), + await selectedConnectionFor(combo), + await selectedConnectionFor(combo), + ], + [first.id, second.id, first.id] + ); +});