mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
feat(combo): per-combo stickyRoundRobinLimit override on the combos page (#4472)
Thanks @adivekar-utexas! Rebased onto release/v3.8.32 (resolved the additive conflict with #3872's queueDepth). Per-combo stickyRoundRobinLimit override now ships.
This commit is contained in:
@@ -1986,11 +1986,17 @@ async function handleRoundRobinCombo({
|
||||
log
|
||||
);
|
||||
|
||||
// Sticky batch size at the combo level. Reuses the global `stickyRoundRobinLimit`
|
||||
// setting so a single knob controls sticky batching for both account fallback and
|
||||
// combo targets. Values <= 1 preserve the historical one-request-per-target rotation.
|
||||
// Sticky batch size at the combo level. A per-combo `stickyRoundRobinLimit` (in
|
||||
// combo.config, resolved through the cascade) overrides the global setting so one
|
||||
// combo can batch differently from the default. When the per-combo value is unset,
|
||||
// fall back to the global `stickyRoundRobinLimit` so the existing knob still controls
|
||||
// sticky batching for both account fallback and combo targets. Values <= 1 preserve
|
||||
// the historical one-request-per-target rotation.
|
||||
const perComboStickyLimit = (config as Record<string, unknown>).stickyRoundRobinLimit;
|
||||
const stickyLimit = clampStickyRoundRobinTargetLimit(
|
||||
(settings as Record<string, unknown> | null)?.stickyRoundRobinLimit
|
||||
perComboStickyLimit !== undefined && perComboStickyLimit !== null
|
||||
? perComboStickyLimit
|
||||
: (settings as Record<string, unknown> | null)?.stickyRoundRobinLimit
|
||||
);
|
||||
const stickyRoundRobinEnabled = stickyLimit > 1;
|
||||
if (
|
||||
|
||||
@@ -157,6 +157,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
|
||||
"Round-robin combo/model limit: max simultaneous requests sent to each model target. This is separate from any provider account-only cap.",
|
||||
queueTimeout:
|
||||
"How long a request can wait for a round-robin model slot before timing out. This queue is separate from any account-only concurrency cap.",
|
||||
stickyLimit:
|
||||
"Round-robin sticky batch size: consecutive successful requests sent to one target before rotating to the next. Empty inherits the global Sticky Limit setting; 1 disables batching (pure one-request rotation).",
|
||||
failoverBeforeRetry:
|
||||
"When enabled, a 429 from the upstream triggers immediate target failover instead of retrying the same URL first.",
|
||||
targetTimeoutMs:
|
||||
@@ -2699,6 +2701,8 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
if (config.concurrencyPerModel !== undefined)
|
||||
configToSave.concurrencyPerModel = config.concurrencyPerModel;
|
||||
if (config.queueTimeoutMs !== undefined) configToSave.queueTimeoutMs = config.queueTimeoutMs;
|
||||
if (config.stickyRoundRobinLimit !== undefined)
|
||||
configToSave.stickyRoundRobinLimit = config.stickyRoundRobinLimit;
|
||||
}
|
||||
const hasConfigToSave = Object.keys(configToSave).length > 0;
|
||||
const hadExistingConfig = Object.keys(sanitizeComboRuntimeConfig(combo?.config)).length > 0;
|
||||
@@ -3789,6 +3793,33 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldLabelWithHelp
|
||||
label={getI18nOrFallback(t, "stickyLimit", "Sticky Limit")}
|
||||
help={getI18nOrFallback(
|
||||
t,
|
||||
"advancedHelp.stickyLimit",
|
||||
ADVANCED_FIELD_HELP_FALLBACK.stickyLimit
|
||||
)}
|
||||
showHelp={!isExpertMode}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000"
|
||||
value={config.stickyRoundRobinLimit ?? ""}
|
||||
placeholder={getI18nOrFallback(t, "stickyLimitInherit", "inherit")}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
stickyRoundRobinLimit: e.target.value
|
||||
? Number(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{strategy === "context-relay" && (
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
|
||||
// ──── Combo Schemas ────
|
||||
|
||||
export const comboStepMetaSchema = {
|
||||
@@ -135,6 +134,10 @@ export const comboRuntimeConfigSchema = z
|
||||
queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(),
|
||||
// #3872: pre-cascade semaphore queue depth (round-robin). 0 = fail over immediately.
|
||||
queueDepth: z.coerce.number().int().min(0).max(100).optional(),
|
||||
// Per-combo sticky round-robin batch size. When unset, handleRoundRobinCombo
|
||||
// falls back to the global `settings.stickyRoundRobinLimit` so the existing
|
||||
// knob still controls the default. 0 clamps to 1 (no batching) upstream.
|
||||
stickyRoundRobinLimit: z.coerce.number().int().min(0).max(1000).optional(),
|
||||
healthCheckEnabled: z.boolean().optional(),
|
||||
healthCheckTimeoutMs: z.coerce.number().int().min(100).max(30000).optional(),
|
||||
handoffThreshold: z.coerce.number().min(0.5).max(0.94).optional(),
|
||||
@@ -312,4 +315,4 @@ export const reorderCombosSchema = z
|
||||
|
||||
export const testComboSchema = z.object({
|
||||
comboName: z.string().trim().min(1, "comboName is required"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,6 +531,24 @@ test("createComboSchema accepts failoverBeforeRetry, maxSetRetries and setRetryD
|
||||
assert.equal(parsed.config.setRetryDelayMs, 1500);
|
||||
});
|
||||
|
||||
test("createComboSchema accepts per-combo stickyRoundRobinLimit and rejects out-of-range", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "sticky-override",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
strategy: "round-robin",
|
||||
config: { stickyRoundRobinLimit: 2 },
|
||||
});
|
||||
assert.equal(parsed.config.stickyRoundRobinLimit, 2);
|
||||
|
||||
const tooHigh = createComboSchema.safeParse({
|
||||
name: "sticky-too-high",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
strategy: "round-robin",
|
||||
config: { stickyRoundRobinLimit: 1001 },
|
||||
});
|
||||
assert.equal(tooHigh.success, false);
|
||||
});
|
||||
|
||||
test("createComboSchema coerces string numbers for maxSetRetries and setRetryDelayMs", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "coerce-test",
|
||||
|
||||
@@ -330,6 +330,32 @@ test("round-robin uses existing stickyRoundRobinLimit for combo target batching"
|
||||
]);
|
||||
});
|
||||
|
||||
test("per-combo stickyRoundRobinLimit overrides the global setting", async () => {
|
||||
const calls: string[] = [];
|
||||
const combo = {
|
||||
name: "rr-per-combo-sticky-override",
|
||||
strategy: "round-robin",
|
||||
models: ["openai/a", "claude/b", "gemini/c"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, stickyRoundRobinLimit: 2 },
|
||||
};
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, m: string) => {
|
||||
calls.push(m);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: { stickyRoundRobinLimit: 3 },
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
assert.deepEqual(calls, ["openai/a", "openai/a", "claude/b", "claude/b"]);
|
||||
});
|
||||
|
||||
test("round-robin sticky batching fallback success becomes sticky target", async () => {
|
||||
const calls: string[] = [];
|
||||
const combo = {
|
||||
|
||||
Reference in New Issue
Block a user