diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index b56bd28877..db7c73390e 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -386,3 +386,4 @@ Hata hedefe özgü olmaktan ziyade uç (terminal) bir hataydı. Geçersiz girdiy düzeltin, aşırı büyük bir bağlamı azaltın, bir politika reddini işleyin veya reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapmaz. + diff --git a/src/cli/combo.ts b/src/cli/combo.ts index de324eed19..3e0aa0d0bf 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -14,7 +14,7 @@ const USAGE = `Usage: ocx combo [list] [--json] ocx combo show [--json] ocx combo set --targets - [--strategy ] [--sticky <1-100>] + [--strategy ] [--sticky <1-100>] [--effort ] [--alias ] [--native-alias] [--display-name ] [--rename-from ] [--json] @@ -73,9 +73,12 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { const targetsRaw = takeOption(args, "--targets"); if (!targetsRaw) throw new CliUsageError("--targets is required", USAGE); const strategy = takeOption(args, "--strategy") ?? "failover"; - if (strategy !== "failover" && strategy !== "round-robin") throw new CliUsageError("--strategy must be failover or round-robin", USAGE); - const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 }) ?? 1; - if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE); + if (strategy !== "failover" && strategy !== "round-robin" && strategy !== "random" && strategy !== "least-used" && strategy !== "reset-window") throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, or reset-window", USAGE); + const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 }); + if (stickyLimit !== undefined) { + if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE); + if (strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE); + } const effort = takeOption(args, "--effort"); const alias = takeOption(args, "--alias"); const nativeAlias = takeFlag(args, "--native-alias"); @@ -84,7 +87,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { rejectArgs(args, USAGE); const combo: Record = { strategy, - stickyLimit, + stickyLimit: stickyLimit ?? 1, targets: parseTargets(targetsRaw), }; if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort; diff --git a/src/cli/help.ts b/src/cli/help.ts index 23c2fd3b42..be3d7a8995 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -61,7 +61,7 @@ Usage: ocx account Accounts, login/reauth, key pools, and quota controls ocx models Live/custom models, visibility, context, and shadow calls ocx alias Short names for providers and models (list, set, rm, defaults) - ocx combo Combo failover/round-robin routing + ocx combo Combo routing strategies and failover ocx agent Subagents, injection, effort caps, and sidecars ocx observe Logs, usage, storage, memory, and debug data ocx inspect Effective config, catalog, analytics, pacing, client-config diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f280826ebd..73dceaf805 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -194,7 +194,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "combo", usage: "ocx combo ...", - summary: "Manage combo failover and round-robin virtual models.", + summary: "Manage combo virtual models and routing strategies.", details: ["Alias hierarchy: ocx route combo ...", "Use --targets provider/model[:weight],provider/model[:weight]."], }, { diff --git a/src/combos/index.ts b/src/combos/index.ts index 571eb540d5..502e210dc6 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -43,3 +43,4 @@ export { concreteComboRequestBody, resetComboEffortWarningStateForTests, } from "./request"; +export { earliestQuotaResetAt, quotaResetRemainingMs } from "./reset-window"; diff --git a/src/combos/reset-window.ts b/src/combos/reset-window.ts new file mode 100644 index 0000000000..0d98a116c6 --- /dev/null +++ b/src/combos/reset-window.ts @@ -0,0 +1,46 @@ +import type { ProviderQuota } from "../providers/quota"; + +function collectResetCandidates(quota: ProviderQuota): number[] { + const candidates: number[] = []; + const push = (value: number | undefined): void => { + if (value !== undefined && Number.isFinite(value)) candidates.push(value); + }; + push(quota.fiveHourResetAt); + push(quota.weeklyResetAt); + push(quota.monthlyResetAt); + if (quota.customWindows) { + for (const w of quota.customWindows) { + push(w.resetAt); + } + } + return candidates; +} + +/** + * Earliest future reset timestamp from a cached provider quota snapshot, + * or null when no fresh quota data exists or all resets have elapsed. + */ +export function earliestQuotaResetAt( + quota: ProviderQuota | null, + now: number, +): number | null { + if (!quota) return null; + const future = collectResetCandidates(quota).filter(ts => ts > now); + if (future.length > 0) return Math.min(...future); + return null; +} + +/** + * Milliseconds until the soonest known quota-window reset. + * Returns Infinity when no quota data exists, quota is stale, or all known + * reset timestamps have elapsed. An elapsed reset is stale evidence — it + * does not prove the next request has fresh capacity. + */ +export function quotaResetRemainingMs( + quota: ProviderQuota | null, + now: number, +): number { + const nearest = earliestQuotaResetAt(quota, now); + if (nearest === null) return Number.POSITIVE_INFINITY; + return nearest - now; +} diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index e2e36601a1..4dc5cc0298 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -1,5 +1,7 @@ import type { OcxComboTarget, OcxConfig } from "../types"; +import { getCachedProviderQuota } from "../providers/quota-routing-cache"; import { coolComboTarget, isComboTargetInCooldown } from "./failover"; +import { quotaResetRemainingMs } from "./reset-window"; import { getCombo, resolveComboId, targetKey } from "./types"; import type { NormalizedComboConfig } from "./types"; import { @@ -19,6 +21,7 @@ interface SelectionState { activeKey?: string; successes: number; currentWeights: Map; + successfulUses: Map; } const selectionState = new Map(); @@ -82,6 +85,36 @@ function smoothWeightedIndex( return best; } +/** + * Select the eligible target whose earliest known quota reset is nearest. + * + * Only reads the last successfully cached provider-quota snapshot; it never + * triggers an upstream quota probe. When no target has fresh reset data, + * every remaining value is Infinity and configured order becomes the + * fallback. Targets with elapsed or stale reset timestamps are treated as + * unknown (Infinity). + */ +function resetWindowIndex( + targets: Required[], + eligible: (target: Required) => boolean, + now = Date.now(), +): number { + let selected = -1; + let smallestRemaining = Number.POSITIVE_INFINITY; + for (let index = 0; index < targets.length; index++) { + const target = targets[index]!; + if (!eligible(target)) continue; + const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now); + // Strict comparison deliberately retains configured order for ties, + // including the no-snapshot fallback where every value is Infinity. + if (selected < 0 || remaining < smallestRemaining) { + selected = index; + smallestRemaining = remaining; + } + } + return selected; +} + export function pickComboTarget( config: OcxConfig, comboId: string, @@ -103,7 +136,7 @@ export function pickComboTarget( if (combo.strategy === "round-robin") { let state = selectionState.get(comboId); if (!state) { - state = { successes: 0, currentWeights: new Map() }; + state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() }; selectionState.set(comboId, state); } if (state.activeKey) { @@ -120,6 +153,41 @@ export function pickComboTarget( state.successes = 0; } } + } else if (combo.strategy === "random") { + // Weighted random selection happens independently for every request. + const eligibleTargets = combo.targets + .map((target, index) => ({ target, index })) + .filter(({ target }) => eligible(target)); + if (eligibleTargets.length > 0) { + const totalWeight = eligibleTargets.reduce((sum, entry) => sum + entry.target.weight, 0); + let random = Math.random() * totalWeight; + for (const entry of eligibleTargets) { + random -= entry.target.weight; + if (random <= 0) { + targetIndex = entry.index; + break; + } + } + if (targetIndex < 0) targetIndex = eligibleTargets[eligibleTargets.length - 1]!.index; + } + } else if (combo.strategy === "least-used") { + let state = selectionState.get(comboId); + if (!state) { + state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() }; + selectionState.set(comboId, state); + } + let fewestUses = Number.POSITIVE_INFINITY; + for (let index = 0; index < combo.targets.length; index++) { + const target = combo.targets[index]!; + if (!eligible(target)) continue; + const uses = state.successfulUses.get(targetKey(target)) ?? 0; + if (targetIndex < 0 || uses < fewestUses) { + targetIndex = index; + fewestUses = uses; + } + } + } else if (combo.strategy === "reset-window") { + targetIndex = resetWindowIndex(combo.targets, eligible); } else { targetIndex = combo.targets.findIndex(eligible); } @@ -141,9 +209,18 @@ export function noteComboSuccess( target: Required, writerGeneration = captureConfigGeneration(), ): void { - if (combo.strategy !== "round-robin") return; const key = targetKey(target); if (!mayCommitComboState(comboId, key, writerGeneration)) return; + if (combo.strategy === "least-used") { + let state = selectionState.get(comboId); + if (!state) { + state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() }; + selectionState.set(comboId, state); + } + state.successfulUses.set(key, (state.successfulUses.get(key) ?? 0) + 1); + return; + } + if (combo.strategy !== "round-robin") return; const state = selectionState.get(comboId); if (!state || state.activeKey !== key) return; state.successes += 1; @@ -206,6 +283,11 @@ export function reconcileComboRotationState(context: GenerationContext): number state.currentWeights.delete(key); removed += 1; } + for (const key of state.successfulUses.keys()) { + if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue; + state.successfulUses.delete(key); + removed += 1; + } } liveComboTargets = new Set(context.comboTargets); lastReconciledGeneration = context.generation; diff --git a/src/combos/types.ts b/src/combos/types.ts index 9e8321de70..cd65e1c35e 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -215,8 +215,11 @@ export function comboConfigIssues( } if (body.strategy !== undefined && body.strategy !== "failover" - && body.strategy !== "round-robin") { - issues.push({ path: ["strategy"], message: 'strategy must be "failover" or "round-robin"' }); + && body.strategy !== "round-robin" + && body.strategy !== "random" + && body.strategy !== "least-used" + && body.strategy !== "reset-window") { + issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", or "reset-window"' }); } if (body.stickyLimit !== undefined && (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit) diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts new file mode 100644 index 0000000000..065d7338ca --- /dev/null +++ b/src/providers/quota-routing-cache.ts @@ -0,0 +1,32 @@ +import type { ProviderQuota, ProviderQuotaReport } from "./quota"; + +const quotaCache = new Map(); + +export function clearCachedProviderQuotas(): void { + quotaCache.clear(); +} + +export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void { + quotaCache.clear(); + for (const report of reports) { + quotaCache.set(report.provider, report.quota); + } +} + +export function getCachedProviderQuota( + provider: string, + now: number, + maxAgeMs = 30 * 60_000, +): ProviderQuota | null { + const quota = quotaCache.get(provider); + if (!quota) return null; + if (now - quota.updatedAt > maxAgeMs) return null; + return quota; +} + +export function setCachedProviderQuotaForTests( + provider: string, + quota: ProviderQuota, +): void { + quotaCache.set(provider, quota); +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 63c74b9cd6..8b6ca0cd12 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -24,6 +24,10 @@ import { type GenerationContext, } from "../lib/state-store-sweeper"; import { readBoundedResponseBody } from "../lib/bounded-body"; +import { + clearCachedProviderQuotas, + replaceCachedProviderQuotas, +} from "./quota-routing-cache"; import { aggregateCodexPoolCapacity, CODEX_CAPACITY_MAX_QUOTA_AGE_MS, @@ -125,6 +129,7 @@ let invalidationEpoch = 0; /** Invalidate the report cache (e.g. after switching a provider's active account). */ export function clearProviderQuotaCache(): void { cache = null; + clearCachedProviderQuotas(); invalidationEpoch += 1; } @@ -1504,6 +1509,7 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider)); removed += cache.response.reports.length - reports.length; cache = { ...cache, response: { ...cache.response, reports } }; + replaceCachedProviderQuotas(reports); } liveAccountQuotaKeys = new Set(context.oauthAccountKeys); liveProviderQuotaKeys = new Set(context.providerNames); @@ -2341,6 +2347,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh ) { const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration)); cache = { key, ts: Date.now(), response: { ...response, reports } }; + replaceCachedProviderQuotas(reports); } return response; })(); diff --git a/src/router.ts b/src/router.ts index 489451de3e..ed7849318a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -489,7 +489,7 @@ export function comboRouteDecisionTrace( reason: "combo-pick", candidateIndex: pick.targetIndex, ...(combo - ? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" } + ? { tieBreak: combo.strategy } : {}), }, candidates: combo ? comboRouteCandidates(config, pick, combo) : undefined, @@ -763,7 +763,7 @@ export function routeModel( reason: route.routeReason, ...(route.combo ? { candidateIndex: route.combo.targetIndex } : {}), ...(combo - ? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" } + ? { tieBreak: combo.strategy } : {}), }, candidates: route.routeKind === "combo" && route.combo && combo diff --git a/src/types/config.ts b/src/types/config.ts index 10a87c9859..1d5f91d318 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -654,19 +654,19 @@ export interface OcxConfig { export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; -export type OcxComboStrategy = "failover" | "round-robin"; +export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; export interface OcxComboTarget { provider: string; model: string; - /** Relative SWRR batch weight. Default 1; valid range 1..10000. */ + /** Relative target weight for round-robin batches and random selection. Default 1; valid range 1..10000. */ weight?: number; } export interface OcxComboConfig { targets: OcxComboTarget[]; - /** Ordered failover (default) or deterministic smooth weighted round-robin. */ + /** Ordered failover (default), round-robin, weighted random, least-used, or quota reset-window selection. */ strategy?: OcxComboStrategy; /** Successful requests retained on one RR selection batch. Default 1; range 1..100. */ stickyLimit?: number; diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index b8faa77ea2..fc703abb20 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -433,6 +433,20 @@ describe("headless GUI parity CLI", () => { }); }); + test("combo set rejects --sticky outside round-robin instead of dropping it", async () => { + const runtime = fakeRuntime(); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const code = await handleComboCommand([ + "set", "demo", "--targets", "a/m1", "--strategy", "random", "--sticky", "5", + ], runtime.deps); + expect(code).toBe(2); + expect(runtime.requests).toEqual([]); + } finally { + errorSpy.mockRestore(); + } + }); + test("combo set forwards the explicit native-alias compatibility contract", async () => { const runtime = fakeRuntime(); const code = await handleComboCommand([ diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5891771737..b0489a9baa 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -1078,4 +1078,72 @@ describe("supported disabled-provider activation", () => { }); }, 10_000); }); + +describe("combo response-path strategy accounting", () => { + function responseRequest(): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: false }), + }); + } + + function completion(label: string): Response { + return Response.json({ + id: `chatcmpl-${label}`, + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: label }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + } + + test("least-used counts successful response-path attempts", async () => { + let aHits = 0; + let bHits = 0; + const upstreamA = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { aHits += 1; return completion("a"); } }); + const upstreamB = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { bHits += 1; return completion("b"); } }); + try { + const config = baseConfig({ + providers: { + a: { adapter: "openai-chat", baseUrl: `${upstreamA.url}v1`, allowPrivateNetwork: true, apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: `${upstreamB.url}v1`, allowPrivateNetwork: true, apiKey: "kb", models: ["m2"] }, + }, + combos: { free: { strategy: "least-used", targets: [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }] } }, + }); + expect((await handleResponses(responseRequest(), config, { model: "", provider: "" })).status).toBe(200); + expect((await handleResponses(responseRequest(), config, { model: "", provider: "" })).status).toBe(200); + expect({ aHits, bHits }).toEqual({ aHits: 1, bHits: 1 }); + } finally { + await upstreamA.stop(true); + await upstreamB.stop(true); + } + }, 10_000); + + test("reset-window retries the next target and cools the failed target", async () => { + let aHits = 0; + let bHits = 0; + const upstreamA = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { aHits += 1; return Response.json({ error: { message: "busy" } }, { status: 429, headers: { "retry-after": "60" } }); }, + }); + const upstreamB = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { bHits += 1; return completion("b"); } }); + try { + const config = baseConfig({ + providers: { + a: { adapter: "openai-chat", baseUrl: `${upstreamA.url}v1`, allowPrivateNetwork: true, apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: `${upstreamB.url}v1`, allowPrivateNetwork: true, apiKey: "kb", models: ["m2"] }, + }, + combos: { free: { strategy: "reset-window", targets: [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }] } }, + }); + const response = await handleResponses(responseRequest(), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect({ aHits, bHits }).toEqual({ aHits: 1, bHits: 1 }); + expect(isComboTargetInCooldown("free", { provider: "a", model: "m1" })).toBe(true); + } finally { + await upstreamA.stop(true); + await upstreamB.stop(true); + } + }, 10_000); +}); import { ManagementRequest as Request } from "./helpers/management-auth"; diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 166a738554..76c0542f38 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -18,6 +18,7 @@ import { comboRequestHasImageInput, concreteComboRequestBody, coolComboTarget, + earliestQuotaResetAt, getCombo, isComboTargetInCooldown, isValidComboId, @@ -45,6 +46,12 @@ import type { OcxConfig } from "../src/types"; import { syncCatalogModels } from "../src/codex/catalog"; import { injectClaudeAgentDefs } from "../src/claude/agents-inject"; import { reconcileComboRotationState } from "../src/combos/resolve"; +import { + clearCachedProviderQuotas, + getCachedProviderQuota, + replaceCachedProviderQuotas, + setCachedProviderQuotaForTests, +} from "../src/providers/quota-routing-cache"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; const VALID_COMBO = { targets: [{ provider: "a", model: "m1" }] }; @@ -156,6 +163,7 @@ async function responseJson(response: Response | null): Promise { clearComboSelectionState(); clearComboTargetCooldowns(); + clearCachedProviderQuotas(); }); describe("combo namespace primitives", () => { @@ -430,6 +438,19 @@ describe("combo failure policy and advancement", () => { }); describe("deterministic combo selection", () => { + test("replacing quota snapshots removes providers omitted from the refresh", () => { + const now = Date.now(); + replaceCachedProviderQuotas([ + { provider: "a", label: "a", source: "test", quota: { updatedAt: now } }, + { provider: "b", label: "b", source: "test", quota: { updatedAt: now } }, + ]); + replaceCachedProviderQuotas([ + { provider: "a", label: "a", source: "test", quota: { updatedAt: now } }, + ]); + expect(getCachedProviderQuota("a", now)).not.toBeNull(); + expect(getCachedProviderQuota("b", now)).toBeNull(); + }); + test("equal-weight RR rotates exactly", () => { const config = rrConfig(1, [1, 1, 1]); expect(successfulPicks(config, 6)).toEqual([ @@ -453,6 +474,114 @@ describe("deterministic combo selection", () => { expect(routeModel(config, "combo/free").providerName).toBe("a"); }); + test("random selection is weighted per request and does not inherit round-robin stickiness", () => { + const roundRobin = rrConfig(2, [1, 1]); + expect(pickComboTarget(roundRobin, "free")?.target.provider).toBe("a"); + + const random = baseConfig({ + combos: { + free: { + strategy: "random", + targets: [ + { provider: "a", model: "m1", weight: 1 }, + { provider: "b", model: "m2", weight: 3 }, + ], + }, + }, + }); + const entropy = spyOn(Math, "random"); + try { + entropy.mockReturnValueOnce(0).mockReturnValueOnce(0.5); + expect(pickComboTarget(random, "free")?.target.provider).toBe("a"); + expect(pickComboTarget(random, "free")?.target.provider).toBe("b"); + } finally { + entropy.mockRestore(); + } + }); + + test("least-used selection counts successful requests and preserves configured ties", () => { + const config = baseConfig({ + combos: { + free: { + strategy: "least-used", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + }, + }); + + expect(successfulPicks(config, 4)).toEqual(["a/m1", "b/m2", "a/m1", "b/m2"]); + }); + + test("reset-window selects the eligible target whose cached quota resets soonest", () => { + const now = Date.now(); + const config = baseConfig({ + combos: { + free: { + strategy: "reset-window", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], + }, + }, + }); + setCachedProviderQuotaForTests("a", { updatedAt: now, fiveHourResetAt: now + 24 * 60 * 60_000 }); + setCachedProviderQuotaForTests("b", { updatedAt: now, weeklyResetAt: now + 60 * 60_000 }); + setCachedProviderQuotaForTests("c", { updatedAt: now }); + + expect(pickComboTarget(config, "free")?.target.provider).toBe("b"); + expect(routeModel(config, "combo/free").routeDecision?.selected).toMatchObject({ + tieBreak: "reset-window", + }); + }); + + test("reset-window treats elapsed resets as unknown and falls back to configured order", () => { + const now = Date.now(); + const config = baseConfig({ + combos: { + free: { + strategy: "reset-window", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], + }, + }, + }); + setCachedProviderQuotaForTests("a", { updatedAt: now, fiveHourResetAt: now - 1 }); + setCachedProviderQuotaForTests("b", { updatedAt: now, weeklyResetAt: now + 60 * 60_000 }); + setCachedProviderQuotaForTests("c", { updatedAt: now, monthlyResetAt: now + 60 * 60_000 }); + expect(pickComboTarget(config, "free")?.target.provider).toBe("b"); + + config.providers.a!.disabled = true; + expect(pickComboTarget(config, "free")?.target.provider).toBe("b"); + + clearCachedProviderQuotas(); + setCachedProviderQuotaForTests("b", { + updatedAt: now - 30 * 60_000 - 1, + weeklyResetAt: now + 1, + }); + expect(pickComboTarget(config, "free")?.target.provider).toBe("b"); + + config.providers.a!.disabled = false; + expect(pickComboTarget(config, "free")?.target.provider).toBe("a"); + }); + + test("reset-window treats non-finite reset timestamps as unknown", () => { + const now = Date.now(); + expect(earliestQuotaResetAt({ updatedAt: now, fiveHourResetAt: Number.POSITIVE_INFINITY }, now)).toBeNull(); + expect(earliestQuotaResetAt({ updatedAt: now, weeklyResetAt: Number.NaN }, now)).toBeNull(); + expect(earliestQuotaResetAt({ + updatedAt: now, + customWindows: [{ label: "burst", percent: 100, resetAt: Number.POSITIVE_INFINITY }], + }, now)).toBeNull(); + }); + test("routes a concrete combo target without re-entering its shadowing alias", () => { const config = baseConfig({ combos: { @@ -592,7 +721,7 @@ describe("combo validation and normalization", () => { { raw: VALID_COMBO, providers: { combo: providers.a! }, path: [], message: 'reserved "combo/" namespace' }, { id: "a", raw: VALID_COMBO, path: [], message: 'combo id "a" collides' }, { raw: null, path: [], message: "combo must be an object" }, - { raw: { ...VALID_COMBO, strategy: "random" }, path: ["strategy"], message: "failover" }, + { raw: { ...VALID_COMBO, strategy: "unexpected" }, path: ["strategy"], message: "failover" }, { raw: { ...VALID_COMBO, stickyLimit: 1.5 }, path: ["stickyLimit"], message: "integer from 1 to 100" }, { raw: { ...VALID_COMBO, defaultEffort: "turbo" }, path: ["defaultEffort"], message: "low, medium, high" }, { raw: { targets: [] }, path: ["targets"], message: "non-empty array" }, @@ -714,7 +843,7 @@ describe("persisted combo config parity", () => { }); const rows: Array<{ id: string; combo: unknown; providers?: OcxConfig["providers"] }> = [ - { id: "free", combo: { ...VALID_COMBO, strategy: "random" } }, + { id: "free", combo: { ...VALID_COMBO, strategy: "unexpected" } }, { id: "free", combo: { ...VALID_COMBO, stickyLimit: 0 } }, { id: "free", combo: { ...VALID_COMBO, defaultEffort: "turbo" } }, { id: "free", combo: { targets: [] } },