Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136)
3 changes: 2 additions & 1 deletion open-sse/services/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2626,7 +2626,8 @@ async function handleRoundRobinCombo({
filteredTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
// stickiness engages on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
);
const rrAffinity = applyPromptCacheAffinity(
filteredTargets,
Expand Down
29 changes: 25 additions & 4 deletions open-sse/services/combo/sessionStickiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
*
* Design
* ──────
* • Hash key: SHA-256 of the FIRST user message → first 16 hex chars.
* • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity
* at production call sites → first 16 hex chars.
* Using only the first message gives a stable key that does not change as
* the conversation grows, yet still identifies the conversation reliably.
* • Headroom gate: before reusing the sticky connection we re-check that its
Expand Down Expand Up @@ -317,6 +318,23 @@ export function deriveMessageHash(
return createHash("sha256").update(text).digest("hex").slice(0, 16);
}

/**
* Keep one conversation's prompt-cache affinity local to the Combo that learned
* it. Without this namespace, two different Combos receiving the same first
* user message share a binding and can silently reorder each other's targets.
* The unscoped form remains available for direct callers and backwards-compatible
* unit seams; production dispatchers always provide their Combo name.
*/
function scopeMessageHash(messageHash: string, namespace?: string): string {
if (!namespace) return messageHash;
return createHash("sha256")
.update(namespace)
.update("\0")
.update(messageHash)
.digest("hex")
.slice(0, 16);
}

/** Evict expired entries and enforce the hard cap. */
function evict(): void {
const now = Date.now();
Expand Down Expand Up @@ -424,19 +442,22 @@ export interface ApplyStickinessResult {
*
* @param orderedTargets Targets already ordered by the combo strategy.
* @param messages Request body.messages.
* @param namespace Combo identity that owns this sticky binding.
* @returns Result with (possibly reordered) targets.
*/
export async function applySessionStickiness(
orderedTargets: ResolvedComboTarget[],
messages: Array<{ role?: string; content?: unknown }> | null | undefined
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
namespace?: string
): Promise<ApplyStickinessResult> {
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };

try {
if (orderedTargets.length <= 1) return noOp;

const messageHash = deriveMessageHash(messages);
if (!messageHash) return noOp;
const rawMessageHash = deriveMessageHash(messages);
if (!rawMessageHash) return noOp;
const messageHash = scopeMessageHash(rawMessageHash, namespace);

const existing = stickyMap.get(messageHash);
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };
Expand Down
3 changes: 2 additions & 1 deletion open-sse/services/combo/targetResolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,8 @@ async function applyContinuityFilters(
initialOrderedTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
// stickiness key is derivable on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
);
let orderedTargets = sticky.targets;
if (!cacheStrategyAffinityApplied) {
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/combo-session-stickiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,36 @@ test("different message hashes can map to different connections", async () => {
assert.equal(r2.targets[0].connectionId, "conn-Y");
});

test("identical first messages do not leak sticky targets across combos", async () => {
injectSat({ util5h: 0.0, util7d: 0.0 });
const messages = [{ role: "user", content: "Shared control prompt" }];
const targets = [
makeTarget("conn-primary"),
makeTarget("conn-intermediate"),
makeTarget("conn-last"),
];

const firstCombo = await applySessionStickiness(targets, messages, "combo-with-last-success");
assert.ok(firstCombo.messageHash);
recordStickyBinding(firstCombo.messageHash, "conn-last");

const repeatedFirstCombo = await applySessionStickiness(
targets,
messages,
"combo-with-last-success"
);
assert.equal(repeatedFirstCombo.stuck, true);
assert.equal(repeatedFirstCombo.targets[0].connectionId, "conn-last");

const secondCombo = await applySessionStickiness(targets, messages, "fresh-priority-combo");
assert.equal(secondCombo.stuck, false);
assert.deepEqual(
secondCombo.targets.map((target) => target.connectionId),
["conn-primary", "conn-intermediate", "conn-last"],
"a binding learned by another combo must not reorder this combo's configured priority"
);
});

test("saturation fetch error → fail-open (original order, no crash)", async () => {
__setStickinessHeadroomFetcherForTests(async (_id: string) => {
throw new Error("network failure");
Expand Down
Loading