fix(combo): preserve omniModel tag in streaming output for round-trip context pinning (#2646)

Integrated into release/v3.8.3
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-05-24 03:07:51 +02:00
committed by GitHub
parent 9c94be6597
commit 40cc555b68
2 changed files with 5 additions and 46 deletions

View File

@@ -1611,47 +1611,7 @@ export async function handleComboChat({
},
});
// FIX #585: Sanitize outbound stream — strip <omniModel> tags from
// visible content so they don't leak to the user. The tag is still
// present in the full response for round-trip context pinning, but
// we clean it from each SSE chunk's content field before delivery.
//
// IMPORTANT: Use a SEPARATE TextDecoder from the transform stream above.
// The transform stream's decoder accumulates UTF-8 state; reusing it here
// would corrupt multi-byte characters split across chunk boundaries.
const sanitizeDecoder = new TextDecoder();
const sanitize = new TransformStream({
transform(chunk, controller) {
const text = sanitizeDecoder.decode(chunk, { stream: true });
if (text) {
if (text.includes("<omniModel>")) {
const cleaned = text.replaceAll(
/(?:\\n|\n|\r)*<omniModel>[^<]+<\/omniModel>(?:\\n|\n|\r)*/g,
""
);
if (cleaned) controller.enqueue(encoder.encode(cleaned));
} else {
controller.enqueue(encoder.encode(text));
}
}
},
flush(controller) {
const tail = sanitizeDecoder.decode();
if (tail) {
if (tail.includes("<omniModel>")) {
const cleaned = tail.replaceAll(
/(?:\\n|\n|\r)*<omniModel>[^<]+<\/omniModel>(?:\\n|\n|\r)*/g,
""
);
if (cleaned) controller.enqueue(encoder.encode(cleaned));
} else {
controller.enqueue(encoder.encode(tail));
}
}
},
});
const transformedStream = res.body.pipeThrough(transform).pipeThrough(sanitize);
const transformedStream = res.body.pipeThrough(transform);
// Add model info as response header for clients that support it
const headers = new Headers(res.headers);
headers.set("X-OmniRoute-Model", modelStr);

View File

@@ -1764,7 +1764,7 @@ test("handleComboChat context cache protection pins the model and tags tool-call
);
});
test("handleComboChat context cache protection sanitizes streamed text tags from client output", async () => {
test("handleComboChat context cache protection preserves omniModel tag in streamed output for round-trip pinning", async () => {
const result = await handleComboChat({
body: { stream: true, messages: [{ role: "user", content: "stream it" }] },
combo: {
@@ -1790,7 +1790,7 @@ test("handleComboChat context cache protection sanitizes streamed text tags from
assert.equal(result.ok, true);
assert.equal(result.headers.get("X-OmniRoute-Model"), "openai/gpt-4o-mini");
assert.match(text, /hello world/);
assert.doesNotMatch(text, /<omniModel>/);
assert.match(text, /<omniModel>openai\/gpt-4o-mini<\/omniModel>/);
});
test("handleComboChat context cache protection injects a hidden tag for tool-call-only streams", async () => {
@@ -1818,7 +1818,7 @@ test("handleComboChat context cache protection injects a hidden tag for tool-cal
const text = await result.text();
assert.equal(result.ok, true);
assert.match(text, /"finish_reason":"tool_calls"/);
assert.doesNotMatch(text, /<omniModel>/);
assert.match(text, /<omniModel>openai\/gpt-4o-mini<\/omniModel>/);
});
test("handleComboChat context cache protection flushes cleanly when a stream ends without content", async () => {
@@ -1842,8 +1842,7 @@ test("handleComboChat context cache protection flushes cleanly when a stream end
assert.equal(result.ok, true);
assert.equal(result.headers.get("X-OmniRoute-Model"), "openai/gpt-4o-mini");
assert.match(text, /data: \[DONE\]/);
assert.match(text, /"content":""/);
assert.doesNotMatch(text, /<omniModel>/);
assert.match(text, /"content":"<omniModel>openai\/gpt-4o-mini<\/omniModel>"/);
});
test("handleComboChat round-robin resolves nested combos and returns inactive when every target is skipped", async () => {