fix: fail over streaming combo responses terminated with empty completions (#10404) (#10744)

validateResponseQuality's streaming-SSE peek only flagged an OpenAI-shape
stream as invalid when it closed WITHOUT ever reaching finish_reason/[DONE]
(#7285 truncation guard). A stream that DOES reach finish_reason: "stop"
but never carries any real content, reasoning, or tool_calls in any chunk
fell through as valid, exactly reproducing the reported content:null /
completion_tokens:0 HTTP 200 for cmd/meta/muse-spark-1.2-contributor.

Add a sibling failover branch for the terminated-but-empty case, mirroring
the existing truncation branch. Tool-calls-only streams are unaffected —
they already short-circuit through the earlier content-detection branch.

Co-authored-by: Markus Hartung <mail@hartmark.se>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-19 11:08:22 -03:00
committed by GitHub
parent d6c4fec2ee
commit cc544db38b
4 changed files with 132 additions and 7 deletions

View File

@@ -0,0 +1 @@
- fix(sse): fail over combo streaming responses that reach `finish_reason` with zero content, reasoning, or tool_calls instead of forwarding a terminated-but-empty completion (#10404)

View File

@@ -516,6 +516,24 @@ export async function validateResponseQuality(
return { valid: false, reason: "streaming openai truncated without finish_reason" };
}
// Issue #10404: an OpenAI-shape stream that DOES reach a terminal
// marker (finish_reason / [DONE]) but never carried any real
// content, reasoning, or tool_calls in any chunk — an upstream
// that burns the whole generation budget and returns
// completion_tokens:0 with an HTTP 200. `anyContentFound` only
// flips true via `isKnownNonClaudeStreamPayload` detecting
// content/reasoning/tool_calls (`hasOpenAICompatibleStreamValue`),
// so a tool_calls-only stream already exits early via the
// `outcome === "content"` branch above and never reaches here —
// this branch only fires on genuinely empty completions.
if (openAi.hasChoicePayload && openAi.hasTerminalMarker && !anyContentFound) {
log.warn?.(
"COMBO",
"Streaming OpenAI-shape response reached finish_reason/[DONE] with no content, reasoning, or tool_calls — marking as invalid for combo failover"
);
return { valid: false, reason: "streaming openai terminated with empty completion" };
}
// Incomplete lifecycle or non-Claude stream — replay all buffered
// bytes. The reader is exhausted so the forwarding reader will
// immediately signal done.

View File

@@ -0,0 +1,103 @@
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 sseStream(body: string): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
}
function makeEmptyButTerminatedOpenAiStream(): Response {
const chunks = [
JSON.stringify({
id: "chatcmpl-10404",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
}),
JSON.stringify({
id: "chatcmpl-10404",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 56447, completion_tokens: 0, total_tokens: 56447 },
}),
];
const body = chunks.map((c) => `data: ${c}\n\n`).join("") + "data: [DONE]\n\n";
return new Response(sseStream(body), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function makeToolCallsOnlyOpenAiStream(): Response {
const chunks = [
JSON.stringify({
id: "chatcmpl-10404-tool",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: "" },
},
],
},
finish_reason: null,
},
],
}),
JSON.stringify({
id: "chatcmpl-10404-tool",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: 0, function: { arguments: '{"city":"SF"}' } }],
},
finish_reason: null,
},
],
}),
JSON.stringify({
id: "chatcmpl-10404-tool",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
usage: { prompt_tokens: 100, completion_tokens: 12, total_tokens: 112 },
}),
];
const body = chunks.map((c) => `data: ${c}\n\n`).join("") + "data: [DONE]\n\n";
return new Response(sseStream(body), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
test("#10404: OpenAI-shape stream that reaches finish_reason:stop with zero content/tool_calls/reasoning should fail over, not pass as valid", async () => {
const res = makeEmptyButTerminatedOpenAiStream();
const out = await validateResponseQuality(res, true, silentLog);
assert.equal(
out.valid,
false,
"expected failover (valid:false) for a terminated-but-empty streaming completion"
);
});
test("#10404 no-regression: OpenAI-shape stream carrying only tool_calls deltas + finish_reason:tool_calls must still pass as valid", async () => {
const res = makeToolCallsOnlyOpenAiStream();
const out = await validateResponseQuality(res, true, silentLog);
assert.equal(out.valid, true, "tool_calls-only streams must not be treated as empty");
});

View File

@@ -137,15 +137,18 @@ test("streaming event: ping only (no content, no terminator) → still valid (re
assert.strictEqual(verdict.valid, true);
});
test("streaming OpenAI finish_reason-only chunk (no content delta) → valid (recognised terminator)", async () => {
// Some reasoning models emit a final `finish_reason: "stop"` chunk with
// no content and no follow-up `data: [DONE]`. That's a legitimate empty
// completion, not a truncation. Sending the `finish_reason` chunk
// WITHOUT a trailing `[DONE]` isolates the new finish_reason check —
// removing it would flip this test to invalid.
test("streaming OpenAI finish_reason-only chunk (no content delta) → invalid (#10404 terminated-but-empty failover)", async () => {
// Some upstreams reach a terminal `finish_reason: "stop"` chunk while
// never emitting any content, reasoning, or tool_calls in any chunk —
// an upstream that burns the whole generation budget and returns
// completion_tokens:0 with an HTTP 200 (#10404). The stream is
// well-formed and properly terminated (not a #7285 truncation), but it
// carries zero usable output, so combo must fail over to a sibling
// target rather than forward the empty completion as a success.
const res = makeSseResponse(
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'
);
const verdict = await validateResponseQuality(res, true, {});
assert.strictEqual(verdict.valid, true);
assert.strictEqual(verdict.valid, false);
assert.match(verdict.reason ?? "", /streaming openai terminated with empty completion/);
});