mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
* fix(combo): detect empty content_block in streaming SSE peek (port from 9router#1382) The bounded SSE peek in validateResponseQuality() treated ANY content_block_start/delta/stop event as proof of real output and stopped buffering immediately, without checking whether the block actually carried text/tool_use content. Some upstreams (reported: DeepSeek, GLM via claude→openai translation) can open and close a text content_block with empty text and no tool_use on tool-heavy requests — the gateway logged success and forwarded a client-visible empty completion, and combo routing never failed over to the next model. Track real content separately from 'a content_block_* event was seen': a tool_use/redacted_thinking block start is self-evidently real signal, a text/thinking block start is not (real content only confirmed via a subsequent delta carrying non-empty text/thinking, or an input_json_delta streaming tool arguments). A completed lifecycle (message_start + message_delta/stop) that never produced real content now fails validateResponseQuality(), matching the existing content_filter empty-stream detection path (#3685). Reported-by: heishen6 (https://github.com/decolua/9router/issues/1382) * refactor(combo): extract SSE lifecycle applier to keep the complexity ratchets at baseline The #1382 empty-content_block peek added a branchy switch inline in parseAccumulatedSse, pushing check:complexity to 2057 > baseline 2056. Move the switch to a module-level applySseLifecycleEvent() and hold the four lifecycle booleans in a single SseLifecycleFlags object threaded through it, so the closure no longer copies flags in and out per event. The per-event predicates (content_block_start / content_block_delta / message_delta) are split into small guard helpers, which keeps the applier flat — cognitive complexity punishes nesting, and an earlier switch-only extraction traded the cyclomatic ratchet for a cognitive regression at 891 > 890. Logic is unchanged; both ratchets are now green (complexity 2055, cognitive-complexity 890) and the #1382 regression tests still pass.
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
/**
|
|
* Issue #1382 (upstream decolua/9router) — a streaming Claude response that
|
|
* opens a `content_block_start` (type "text", initial text "") and then
|
|
* immediately `content_block_stop`s WITHOUT ever emitting a
|
|
* `content_block_delta` carrying real text/tool_use content must be treated
|
|
* as an empty/malformed response, not a valid completion.
|
|
*
|
|
* Before this fix, `validateResponseQuality`'s bounded SSE peek stopped
|
|
* buffering (and reported `valid: true`) as soon as ANY content_block_*
|
|
* event was observed — including a content_block_start whose block never
|
|
* carries usable text. Tool-heavy requests against backends that mishandle
|
|
* tool definitions (reported: DeepSeek, GLM via claude→openai translation)
|
|
* can emit exactly this shape: a lifecycle that "completes" successfully at
|
|
* the transport layer while the client receives no usable content. The
|
|
* combo loop never saw this as a failure, so no failover to the next model
|
|
* in the combo ever happened.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { validateResponseQuality } = await import("../../open-sse/services/combo.ts");
|
|
|
|
const encoder = new TextEncoder();
|
|
const silentLog = { warn: () => {} };
|
|
|
|
function claudeSseStream(events: string[]): ReadableStream<Uint8Array> {
|
|
const body = events.join("\n") + "\n";
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(body));
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a mock Claude 200 streaming response with a content_block_start/stop
|
|
* pair carrying EMPTY text and no tool_use block — the shape reported in
|
|
* #1382 for tool-heavy claude→openai requests against DeepSeek/GLM.
|
|
*/
|
|
function makeEmptyTextBlockStream(): Response {
|
|
const events = [
|
|
`event: message_start\ndata: ${JSON.stringify({
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg_test_1382",
|
|
type: "message",
|
|
role: "assistant",
|
|
model: "deepseek-v4-pro-max",
|
|
content: [],
|
|
stop_reason: null,
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 19882, output_tokens: 0 },
|
|
},
|
|
})}`,
|
|
"",
|
|
`event: content_block_start\ndata: ${JSON.stringify({
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "text", text: "" },
|
|
})}`,
|
|
"",
|
|
`event: content_block_stop\ndata: ${JSON.stringify({
|
|
type: "content_block_stop",
|
|
index: 0,
|
|
})}`,
|
|
"",
|
|
`event: message_delta\ndata: ${JSON.stringify({
|
|
type: "message_delta",
|
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
usage: { input_tokens: 0, output_tokens: 25 },
|
|
})}`,
|
|
"",
|
|
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`,
|
|
"",
|
|
];
|
|
|
|
return new Response(claudeSseStream(events), {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
|
|
test("#1382 streaming Claude response with empty content_block (no text, no tool_use) is marked invalid", async () => {
|
|
const res = makeEmptyTextBlockStream();
|
|
const out = await validateResponseQuality(res, true, silentLog);
|
|
assert.equal(
|
|
out.valid,
|
|
false,
|
|
`expected invalid for empty content_block stream, got valid=true (reason: ${out.reason})`
|
|
);
|
|
assert.match(out.reason ?? "", /empty/i, `reason should mention 'empty', got: "${out.reason}"`);
|
|
});
|
|
|
|
test("#1382 streaming Claude response with a real tool_use content_block_start remains valid", async () => {
|
|
const events = [
|
|
`event: message_start\ndata: ${JSON.stringify({
|
|
type: "message_start",
|
|
message: {
|
|
id: "msg_test_1382_tool",
|
|
type: "message",
|
|
role: "assistant",
|
|
model: "deepseek-v4-pro-max",
|
|
content: [],
|
|
stop_reason: null,
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 100, output_tokens: 0 },
|
|
},
|
|
})}`,
|
|
"",
|
|
`event: content_block_start\ndata: ${JSON.stringify({
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} },
|
|
})}`,
|
|
"",
|
|
`event: content_block_stop\ndata: ${JSON.stringify({
|
|
type: "content_block_stop",
|
|
index: 0,
|
|
})}`,
|
|
"",
|
|
`event: message_delta\ndata: ${JSON.stringify({
|
|
type: "message_delta",
|
|
delta: { stop_reason: "tool_use", stop_sequence: null },
|
|
usage: { input_tokens: 0, output_tokens: 12 },
|
|
})}`,
|
|
"",
|
|
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`,
|
|
"",
|
|
];
|
|
const res = new Response(claudeSseStream(events), {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
const out = await validateResponseQuality(res, true, silentLog);
|
|
assert.equal(out.valid, true, `expected valid for tool_use stream, got invalid: ${out.reason}`);
|
|
assert.ok(out.clonedResponse, "clonedResponse must be present for valid streaming response");
|
|
});
|