mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(combo): add option to disable session stickiness (#6168) — per-combo/global, precedence config→settings→false (preserves #3825). TDD guard combo-disable-session-stickiness.test.ts (8/8). Base-reds only. Integrated into release/v3.8.45. (thanks @RCrushMe)
This commit is contained in:
committed by
GitHub
parent
cefbcfb278
commit
1044821bda
@@ -8,6 +8,7 @@
|
||||
- **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18).
|
||||
- **feat(providers):** **bulk-add API keys for Cloudflare Workers AI** ([#6174](https://github.com/diegosouzapw/OmniRoute/issues/6174)) — `cloudflare-ai` is removed from the bulk-add exclusion list and the bulk parser gains a 3-field `name|accountId|apiKey` mode; the bulk route now builds a **per-entry** `providerSpecificData` so each key carries its own `accountId` (fixing the previous shared-object reuse), and both the create + key-validation paths receive it. Regression guard: `tests/unit/bulk-api-key-parser-cloudflare.test.ts`. (thanks @muflifadla38)
|
||||
- **feat(dashboard):** routing/settings UX clarity ([#6147](https://github.com/diegosouzapw/OmniRoute/issues/6147)) — (1) weighted combos show the **effective routing share %** next to each weight when weights don't sum to 100 (`WeightTotalBar.tsx`); (2) the status widget's user-facing **"Cloud Sync" label is renamed** to "Remote Settings Sync" (`CloudSyncStatus.tsx`; internal ids/state untouched); (3) built-in providers gain an **opt-in advanced base-URL override** (`isBaseUrlOverrideEligibleProvider`, hidden behind an "Advanced" toggle, reusing the existing `providerSpecificData.baseUrl` persistence — not globally widened). Regression guard: `tests/unit/routing-settings-ux-6147.test.ts`.
|
||||
- **feat(combo):** add an option to **disable session stickiness**, per-combo or globally — round-robin / random combos can rotate to a different connection on every request instead of pinning a whole conversation to one connection by its first-message hash. Resolution precedence per-combo `config.disableSessionStickiness` → global `settings.disableSessionStickiness` → default `false` (preserves the #3825 prompt-cache/504 fix); gates **both** stickiness call sites in `open-sse/services/combo.ts`. Exposed as a global toggle (Combo Defaults) and a per-combo Inherit/on/off control. ([#6168](https://github.com/diegosouzapw/OmniRoute/issues/6168)) Regression guard: `tests/unit/combo-disable-session-stickiness.test.ts`. (thanks @RCrushMe)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
@@ -58,7 +58,11 @@ import { handlePipelineCombo, buildPipelineResponse } from "./autoCombo/pipeline
|
||||
import { type ProviderCandidate } from "./autoCombo/scoring.ts";
|
||||
import { estimateTokens } from "./contextManager.ts";
|
||||
import { getSessionConnection } from "./sessionManager.ts";
|
||||
import { applySessionStickiness, recordStickyBinding } from "./combo/sessionStickiness.ts";
|
||||
import {
|
||||
applySessionStickiness,
|
||||
recordStickyBinding,
|
||||
resolveDisableSessionStickiness,
|
||||
} from "./combo/sessionStickiness.ts";
|
||||
import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts";
|
||||
import { makeConnectionConcurrencyResolver, lookupPositiveCap } from "./combo/concurrencyCaps.ts";
|
||||
import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts";
|
||||
@@ -1069,10 +1073,20 @@ export async function handleComboChat({
|
||||
apiKeyAllowedConnections,
|
||||
});
|
||||
}
|
||||
const _sticky = await applySessionStickiness(
|
||||
orderedTargets,
|
||||
body.messages as Array<{ role?: string; content?: unknown }>
|
||||
// #6168: session stickiness opt-out. Per-combo `config.disableSessionStickiness`
|
||||
// overrides the global `settings.disableSessionStickiness` fallback (default false,
|
||||
// preserving the #3825 prompt-cache/504 fix). When disabled, skip the reorder and
|
||||
// treat the result as a no-op so the recordStickyBinding write-back below is skipped.
|
||||
const disableSessionStickiness = resolveDisableSessionStickiness(
|
||||
config as Record<string, unknown> | null | undefined,
|
||||
settings as Record<string, unknown> | null | undefined
|
||||
);
|
||||
const _sticky = disableSessionStickiness
|
||||
? ({ targets: orderedTargets, messageHash: null, stuck: false } as const)
|
||||
: await applySessionStickiness(
|
||||
orderedTargets,
|
||||
body.messages as Array<{ role?: string; content?: unknown }>
|
||||
);
|
||||
orderedTargets = _sticky.targets;
|
||||
orderedTargets = orderTargetsByEvalScores(orderedTargets, config.evalRouting, log);
|
||||
orderedTargets = filterTargetsByRequestCompatibility(orderedTargets, body, log);
|
||||
@@ -2389,10 +2403,19 @@ async function handleRoundRobinCombo({
|
||||
// call — so sessionless RR combos rotated every turn, busting the upstream prompt-cache.
|
||||
// Reuse the SAME mechanism: start the rotation at the conversation's sticky connection
|
||||
// (the loop still falls through to the other targets on failure → failover preserved).
|
||||
const _rrSessionSticky = await applySessionStickiness(
|
||||
filteredTargets,
|
||||
body?.messages as Array<{ role?: string; content?: unknown }>
|
||||
// #6168: honor the session-stickiness opt-out here too, otherwise round-robin would
|
||||
// still pin the conversation even when the flag is set. Per-combo `config` overrides
|
||||
// the global `settings.disableSessionStickiness` fallback (default false).
|
||||
const disableSessionStickiness = resolveDisableSessionStickiness(
|
||||
config as Record<string, unknown> | null | undefined,
|
||||
settings as Record<string, unknown> | null | undefined
|
||||
);
|
||||
const _rrSessionSticky = disableSessionStickiness
|
||||
? ({ targets: filteredTargets, messageHash: null, stuck: false } as const)
|
||||
: await applySessionStickiness(
|
||||
filteredTargets,
|
||||
body?.messages as Array<{ role?: string; content?: unknown }>
|
||||
);
|
||||
let rrStartIndex = startIndex;
|
||||
if (_rrSessionSticky.stuck) {
|
||||
const stickyIdx = filteredTargets.findIndex(
|
||||
|
||||
@@ -191,6 +191,26 @@ export function clearAllStickyBindings(): void {
|
||||
stickyMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* #6168: resolve the session-stickiness opt-out for a combo request.
|
||||
*
|
||||
* Precedence (mirrors the `stickyRoundRobinLimit` resolution in combo.ts):
|
||||
* per-combo `config.disableSessionStickiness` (boolean) →
|
||||
* global `settings.disableSessionStickiness` (boolean) →
|
||||
* default `false`.
|
||||
*
|
||||
* Default `false` preserves the #3825 prompt-cache/504 fix — only an explicit
|
||||
* `true` at either level disables stickiness.
|
||||
*/
|
||||
export function resolveDisableSessionStickiness(
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
settings: Record<string, unknown> | null | undefined
|
||||
): boolean {
|
||||
const perCombo = config?.disableSessionStickiness;
|
||||
if (typeof perCombo === "boolean") return perCombo;
|
||||
return settings?.disableSessionStickiness === true;
|
||||
}
|
||||
|
||||
// ─── Core: apply stickiness to an ordered target list ────────────────────────
|
||||
|
||||
export interface ApplyStickinessResult {
|
||||
|
||||
@@ -3972,6 +3972,54 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
<option value="execute">Execute nested combos as targets</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* #6168: per-combo session-stickiness override (tri-state so it can
|
||||
force ON or OFF regardless of the global default; blank = inherit). */}
|
||||
<div>
|
||||
<FieldLabelWithHelp
|
||||
label={getI18nOrFallback(
|
||||
t,
|
||||
"disableSessionStickiness",
|
||||
"Disable session stickiness"
|
||||
)}
|
||||
help={getI18nOrFallback(
|
||||
t,
|
||||
"advancedHelp.disableSessionStickiness",
|
||||
"Rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Overrides the global default. Leave on Inherit to preserve prompt-cache hits for multi-turn chats."
|
||||
)}
|
||||
showHelp={!isExpertMode}
|
||||
/>
|
||||
<select
|
||||
value={
|
||||
config.disableSessionStickiness === true
|
||||
? "disabled"
|
||||
: config.disableSessionStickiness === false
|
||||
? "enabled"
|
||||
: "inherit"
|
||||
}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
disableSessionStickiness:
|
||||
e.target.value === "disabled"
|
||||
? true
|
||||
: e.target.value === "enabled"
|
||||
? false
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-surface-1 focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="inherit">
|
||||
{getI18nOrFallback(t, "stickyLimitInherit", "inherit")}
|
||||
</option>
|
||||
<option value="enabled">
|
||||
{getI18nOrFallback(t, "sessionStickinessEnabled", "Stickiness on")}
|
||||
</option>
|
||||
<option value="disabled">
|
||||
{getI18nOrFallback(t, "sessionStickinessDisabled", "Stickiness off")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{strategy === "context-relay" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { matchesSearch } from "@/shared/utils/turkishText";
|
||||
import FusionDefaultsFields from "./FusionDefaultsFields";
|
||||
import {
|
||||
ROUTING_STRATEGIES,
|
||||
@@ -97,6 +98,7 @@ export default function ComboDefaultsTab() {
|
||||
handoffModel: "",
|
||||
maxMessagesForSummary: 30,
|
||||
stickyRoundRobinLimit: 3,
|
||||
disableSessionStickiness: false,
|
||||
resetAwareQuotaCacheTtlMs: 0,
|
||||
resetAwareQuotaCacheMaxStaleMs: 0,
|
||||
zeroLatencyOptimizationsEnabled: false,
|
||||
@@ -168,6 +170,10 @@ export default function ComboDefaultsTab() {
|
||||
settingsData.stickyRoundRobinLimit ??
|
||||
comboData.comboDefaults?.stickyRoundRobinLimit ??
|
||||
prev.stickyRoundRobinLimit,
|
||||
disableSessionStickiness:
|
||||
settingsData.disableSessionStickiness ??
|
||||
comboData.comboDefaults?.disableSessionStickiness ??
|
||||
prev.disableSessionStickiness,
|
||||
}));
|
||||
if (comboData.providerOverrides) {
|
||||
setProviderOverrides(sanitizeProviderOverrides(comboData.providerOverrides));
|
||||
@@ -214,10 +220,14 @@ export default function ComboDefaultsTab() {
|
||||
const saveComboDefaults = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const { stickyRoundRobinLimit, ...comboDefaultsPayload } = comboDefaults;
|
||||
const { stickyRoundRobinLimit, disableSessionStickiness, ...comboDefaultsPayload } =
|
||||
comboDefaults;
|
||||
const settingsPatch = {
|
||||
...toGlobalRoutingPatch(comboDefaults.strategy, stickyRoundRobinLimit),
|
||||
codexSessionAffinityTtlMs,
|
||||
// #6168: global session-stickiness opt-out — persisted top-level on settings
|
||||
// (mirrors stickyRoundRobinLimit) so combo.ts resolution reads settings.disableSessionStickiness.
|
||||
disableSessionStickiness: disableSessionStickiness === true,
|
||||
};
|
||||
|
||||
const comboDefaultsRes = await fetch("/api/settings/combo-defaults", {
|
||||
@@ -283,7 +293,7 @@ export default function ComboDefaultsTab() {
|
||||
// Filtered provider list — excludes already-added ones, filtered by search query
|
||||
const filteredProviders = availableProviders.filter(
|
||||
(p) =>
|
||||
!providerOverrides[p.provider] && p.provider.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
!providerOverrides[p.provider] && matchesSearch(p.provider, searchQuery)
|
||||
);
|
||||
|
||||
const handleDropdownKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -712,6 +722,29 @@ export default function ComboDefaultsTab() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{translateOrFallback(t, "disableSessionStickiness", "Disable session stickiness")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"disableSessionStickinessDesc",
|
||||
"Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={comboDefaults.disableSessionStickiness === true}
|
||||
onChange={() =>
|
||||
setComboDefaults((prev) => ({
|
||||
...prev,
|
||||
disableSessionStickiness: prev.disableSessionStickiness !== true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Overrides */}
|
||||
|
||||
@@ -2514,7 +2514,8 @@
|
||||
"queueTimeout": "How long a request can wait in queue before timing out.",
|
||||
"failoverBeforeRetry": "When enabled, any upstream error triggers immediate failover to the next combo target, skipping all retries and fallback URLs.",
|
||||
"maxSetRetries": "Number of times to retry the full target set when every target fails. 0 = no set-level retry.",
|
||||
"setRetryDelayMs": "Delay between set-level retry attempts, giving transient issues time to resolve."
|
||||
"setRetryDelayMs": "Delay between set-level retry attempts, giving transient issues time to resolve.",
|
||||
"disableSessionStickiness": "Rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Overrides the global default. Leave on Inherit to preserve prompt-cache hits for multi-turn chats."
|
||||
},
|
||||
"templatesTitle": "Quick templates",
|
||||
"templatesDescription": "Apply a starting profile, then adjust models and config.",
|
||||
@@ -2828,7 +2829,10 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000",
|
||||
"compressionOverride": "Compression Override",
|
||||
"modePack": "Mode Pack"
|
||||
"modePack": "Mode Pack",
|
||||
"disableSessionStickiness": "Disable session stickiness",
|
||||
"sessionStickinessEnabled": "Stickiness on",
|
||||
"sessionStickinessDisabled": "Stickiness off"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Costs",
|
||||
@@ -6152,7 +6156,9 @@
|
||||
"modelLockoutExponentialBackoff": "Exponential Backoff",
|
||||
"modelLockoutExponentialBackoffDescription": "When enabled, each consecutive failure increases the cooldown duration exponentially.",
|
||||
"modelLockoutMaxBackoffSteps": "Max Backoff Steps",
|
||||
"modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised."
|
||||
"modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.",
|
||||
"disableSessionStickiness": "Disable session stickiness",
|
||||
"disableSessionStickinessDesc": "Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence."
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function getSettings() {
|
||||
tailscaleEnabled: false,
|
||||
tailscaleUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
disableSessionStickiness: false,
|
||||
requestRetry: 3,
|
||||
maxRetryIntervalSec: 30,
|
||||
antigravitySignatureCacheMode: "enabled",
|
||||
|
||||
@@ -159,6 +159,12 @@ export const comboRuntimeConfigSchema = z
|
||||
// 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(),
|
||||
// #6168: opt-out for per-conversation session stickiness. When true, round-robin
|
||||
// and random/weighted/priority combos rotate freely instead of pinning a whole
|
||||
// conversation to one connection by the first-message hash. Per-combo `config`
|
||||
// wins over the global `settings.disableSessionStickiness` fallback. Default false
|
||||
// preserves the #3825 prompt-cache/504 fix.
|
||||
disableSessionStickiness: z.boolean().optional(),
|
||||
stickyWeightedLimit: z.coerce.number().int().min(0).max(1000).optional(),
|
||||
healthCheckEnabled: z.boolean().optional(),
|
||||
healthCheckTimeoutMs: z.coerce.number().int().min(100).max(30000).optional(),
|
||||
|
||||
@@ -183,6 +183,8 @@ export const updateSettingsSchema = z.object({
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
// #6168: global session-stickiness opt-out (per-combo config overrides this).
|
||||
disableSessionStickiness: z.boolean().optional(),
|
||||
requestRetry: z.number().int().min(0).max(10).optional(),
|
||||
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
|
||||
maxBodySizeMb: z
|
||||
|
||||
233
tests/unit/combo-disable-session-stickiness.test.ts
Normal file
233
tests/unit/combo-disable-session-stickiness.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* tests/unit/combo-disable-session-stickiness.test.ts
|
||||
*
|
||||
* #6168 — "disable session stickiness" opt-out toggle.
|
||||
*
|
||||
* Two things are covered:
|
||||
* 1. resolveDisableSessionStickiness(config, settings) — the real production
|
||||
* resolver used at BOTH call sites in open-sse/services/combo.ts:
|
||||
* per-combo config.disableSessionStickiness (boolean)
|
||||
* → global settings.disableSessionStickiness (boolean)
|
||||
* → default false.
|
||||
* 2. The GATE behavior — the exact ternary combo.ts uses at both the main
|
||||
* dispatch loop (combo.ts ~1078) and the round-robin handler (combo.ts ~2404):
|
||||
* const disable = resolveDisableSessionStickiness(config, settings);
|
||||
* const result = disable
|
||||
* ? { targets, messageHash: null, stuck: false }
|
||||
* : await applySessionStickiness(targets, messages);
|
||||
* With the flag ON, applySessionStickiness must NOT run (targets left as-is,
|
||||
* stuck:false, messageHash:null → recordStickyBinding write-back is skipped)
|
||||
* EVEN THOUGH a healthy sticky binding exists. With the flag OFF/absent the
|
||||
* conversation still pins to its sticky connection (#3825 preserved).
|
||||
*
|
||||
* Saturation is injected via __setStickinessHeadroomFetcherForTests — no network,
|
||||
* no DB, fully deterministic.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { HeadroomSaturation } from "../../open-sse/services/combo/headroomRanking.ts";
|
||||
import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts";
|
||||
|
||||
const mod = await import("../../open-sse/services/combo/sessionStickiness.ts");
|
||||
const {
|
||||
applySessionStickiness,
|
||||
recordStickyBinding,
|
||||
clearAllStickyBindings,
|
||||
deriveMessageHash,
|
||||
resolveDisableSessionStickiness,
|
||||
__setStickinessHeadroomFetcherForTests,
|
||||
} = mod;
|
||||
|
||||
function makeTarget(connectionId: string): ResolvedComboTarget {
|
||||
return {
|
||||
kind: "model",
|
||||
stepId: `step-${connectionId}`,
|
||||
executionKey: `key-${connectionId}`,
|
||||
modelStr: `gpt-4/${connectionId}`,
|
||||
provider: "openai",
|
||||
providerId: null,
|
||||
connectionId,
|
||||
weight: 1,
|
||||
label: null,
|
||||
};
|
||||
}
|
||||
|
||||
function injectSat(sat: HeadroomSaturation | undefined): void {
|
||||
__setStickinessHeadroomFetcherForTests(async (_id: string) => sat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful reproduction of the gate the production call sites use (combo.ts
|
||||
* ~1078 main dispatch and ~2404 round-robin — the expression is identical at
|
||||
* both). `applySessionStickiness` is wrapped so we can OBSERVE whether it ran.
|
||||
*/
|
||||
async function gatedStickiness(
|
||||
targets: ResolvedComboTarget[],
|
||||
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
settings: Record<string, unknown> | null | undefined,
|
||||
observe: { called: number }
|
||||
) {
|
||||
const disable = resolveDisableSessionStickiness(config, settings);
|
||||
if (disable) {
|
||||
// No-op — applySessionStickiness NOT invoked (recordStickyBinding skipped).
|
||||
return { targets, messageHash: null as string | null, stuck: false };
|
||||
}
|
||||
observe.called += 1;
|
||||
return applySessionStickiness(targets, messages);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearAllStickyBindings();
|
||||
injectSat({ util5h: 0.1, util7d: 0.1 }); // healthy — headroom ~0.9
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
__setStickinessHeadroomFetcherForTests(null);
|
||||
});
|
||||
|
||||
// ─── resolveDisableSessionStickiness (production resolver) ────────────────────
|
||||
|
||||
test("resolver: default false when neither config nor settings set it", () => {
|
||||
assert.equal(resolveDisableSessionStickiness({}, {}), false);
|
||||
assert.equal(resolveDisableSessionStickiness(null, null), false);
|
||||
assert.equal(resolveDisableSessionStickiness(undefined, undefined), false);
|
||||
});
|
||||
|
||||
test("resolver: global settings fallback (AC #3 global on)", () => {
|
||||
assert.equal(
|
||||
resolveDisableSessionStickiness({}, { disableSessionStickiness: true }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
resolveDisableSessionStickiness({}, { disableSessionStickiness: false }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("resolver: per-combo config wins over global (AC #3 precedence)", () => {
|
||||
// combo true beats global false
|
||||
assert.equal(
|
||||
resolveDisableSessionStickiness(
|
||||
{ disableSessionStickiness: true },
|
||||
{ disableSessionStickiness: false }
|
||||
),
|
||||
true
|
||||
);
|
||||
// combo false beats global true
|
||||
assert.equal(
|
||||
resolveDisableSessionStickiness(
|
||||
{ disableSessionStickiness: false },
|
||||
{ disableSessionStickiness: true }
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("resolver: non-boolean per-combo value is ignored, falls back to global", () => {
|
||||
assert.equal(
|
||||
resolveDisableSessionStickiness(
|
||||
{ disableSessionStickiness: "true" as unknown as boolean },
|
||||
{ disableSessionStickiness: true }
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Gate behavior — flag ON bypasses stickiness on both paths ────────────────
|
||||
|
||||
test("flag ON (per-combo): applySessionStickiness is bypassed even with a healthy binding", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")];
|
||||
const messages = [{ role: "user", content: "identical first message" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-C"); // would normally pin conn-C to index 0
|
||||
|
||||
const observe = { called: 0 };
|
||||
const result = await gatedStickiness(
|
||||
targets,
|
||||
messages,
|
||||
{ disableSessionStickiness: true },
|
||||
{ disableSessionStickiness: false },
|
||||
observe
|
||||
);
|
||||
|
||||
assert.equal(observe.called, 0, "applySessionStickiness must NOT be called when disabled");
|
||||
assert.equal(result.stuck, false, "no stickiness applied");
|
||||
assert.equal(result.messageHash, null, "no hash → recordStickyBinding write-back skipped");
|
||||
assert.deepEqual(
|
||||
result.targets.map((t) => t.connectionId),
|
||||
["conn-A", "conn-B", "conn-C"],
|
||||
"targets left untouched (no reordering to the sticky connection)"
|
||||
);
|
||||
});
|
||||
|
||||
test("flag ON (global settings): identical first message fans out to distinct targets across N requests", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")];
|
||||
const messages = [{ role: "user", content: "same first message every time" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-B");
|
||||
|
||||
// Simulate the round-robin start-index rotation the RR handler applies AFTER
|
||||
// the (now-bypassed) stickiness step: rrStartIndex stays at the rotation
|
||||
// counter instead of being overridden to the sticky target's index.
|
||||
const firstOfEach: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const observe = { called: 0 };
|
||||
const result = await gatedStickiness(
|
||||
targets,
|
||||
messages,
|
||||
{},
|
||||
{ disableSessionStickiness: true },
|
||||
observe
|
||||
);
|
||||
assert.equal(observe.called, 0);
|
||||
assert.equal(result.stuck, false);
|
||||
// Rotation is free to pick index i since stickiness did not force index 0.
|
||||
firstOfEach.push(result.targets[i % targets.length].connectionId!);
|
||||
}
|
||||
assert.deepEqual(
|
||||
firstOfEach,
|
||||
["conn-A", "conn-B", "conn-C"],
|
||||
"rotation is free — not collapsed onto the sticky connection"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Regression: flag OFF/absent preserves #3825 stickiness ───────────────────
|
||||
|
||||
test("flag OFF/absent: identical first message still pins to the same target (#3825 preserved)", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")];
|
||||
const messages = [{ role: "user", content: "identical first message" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-C");
|
||||
|
||||
const observe = { called: 0 };
|
||||
const r1 = await gatedStickiness(targets, messages, {}, {}, observe);
|
||||
const r2 = await gatedStickiness(targets, messages, {}, {}, observe);
|
||||
|
||||
assert.equal(observe.called, 2, "applySessionStickiness runs on the default path");
|
||||
assert.ok(r1.stuck, "first request pins to sticky connection");
|
||||
assert.ok(r2.stuck, "second request stays pinned");
|
||||
assert.equal(r1.targets[0].connectionId, "conn-C");
|
||||
assert.equal(r2.targets[0].connectionId, "conn-C");
|
||||
});
|
||||
|
||||
test("flag explicit false (per-combo) also preserves stickiness", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "pin me" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-B");
|
||||
|
||||
const observe = { called: 0 };
|
||||
const result = await gatedStickiness(
|
||||
targets,
|
||||
messages,
|
||||
{ disableSessionStickiness: false },
|
||||
{ disableSessionStickiness: true }, // per-combo false must win
|
||||
observe
|
||||
);
|
||||
|
||||
assert.equal(observe.called, 1, "per-combo false → stickiness still runs");
|
||||
assert.ok(result.stuck);
|
||||
assert.equal(result.targets[0].connectionId, "conn-B");
|
||||
});
|
||||
Reference in New Issue
Block a user