diff --git a/src/cli/combo.ts b/src/cli/combo.ts index de324eed19..73f72f643b 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,7 +73,7 @@ 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); + 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 }) ?? 1; if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE); const effort = takeOption(args, "--effort"); @@ -84,9 +84,9 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { rejectArgs(args, USAGE); const combo: Record = { strategy, - stickyLimit, targets: parseTargets(targetsRaw), }; + if (strategy === "round-robin") combo.stickyLimit = stickyLimit; if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort; if (alias !== undefined) combo.alias = alias === "-" ? "" : alias; if (nativeAlias) combo.nativeAlias = true; diff --git a/src/cli/help.ts b/src/cli/help.ts index c0408e659a..75b7240844 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -52,7 +52,7 @@ Usage: ocx provider Providers, connectivity, quota, and selected models ocx account Accounts, login/reauth, key pools, and quota controls ocx models Live/custom models, visibility, context, and shadow calls - 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 route Routing features (combo, policy) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 85bb3959e5..641b1b64f8 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -189,7 +189,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..83e73dcb54 --- /dev/null +++ b/src/combos/reset-window.ts @@ -0,0 +1,43 @@ +import type { ProviderQuota } from "../providers/quota"; + +function collectResetCandidates(quota: ProviderQuota): number[] { + const candidates: number[] = []; + if (typeof quota.fiveHourResetAt === "number") candidates.push(quota.fiveHourResetAt); + if (typeof quota.weeklyResetAt === "number") candidates.push(quota.weeklyResetAt); + if (typeof quota.monthlyResetAt === "number") candidates.push(quota.monthlyResetAt); + if (quota.customWindows) { + for (const w of quota.customWindows) { + if (typeof w.resetAt === "number") candidates.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 d1ac034096..6941e68358 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -205,8 +205,11 @@ export function comboConfigIssues( const body = raw as Record; 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 db0202161d..ea27cb737b 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -22,6 +22,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, @@ -123,6 +127,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; } @@ -1446,6 +1451,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); @@ -2283,6 +2289,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 47a604d77c..33c7ee7904 100644 --- a/src/router.ts +++ b/src/router.ts @@ -482,7 +482,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, @@ -725,7 +725,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 3e1801f08d..afc694076a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -587,7 +587,7 @@ 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 { @@ -599,7 +599,7 @@ export interface OcxComboTarget { 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/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 49ec7b53b6..d627e22186 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -1036,4 +1036,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 70bf3a657b..acd83d27bb 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -45,6 +45,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 +162,7 @@ async function responseJson(response: Response | null): Promise { clearComboSelectionState(); clearComboTargetCooldowns(); + clearCachedProviderQuotas(); }); describe("combo namespace primitives", () => { @@ -426,6 +433,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([ @@ -449,6 +469,104 @@ 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("routes a concrete combo target without re-entering its shadowing alias", () => { const config = baseConfig({ combos: { @@ -588,7 +706,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" }, @@ -710,7 +828,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: [] } },