fix(sse): harden against empty responses causing Copilot Chat failures (#3297)

Integrated into release/v3.8.13
This commit is contained in:
Wilson
2026-06-06 12:17:53 -03:00
committed by GitHub
parent 744d7ac5fe
commit bc93ffb347
3 changed files with 220 additions and 0 deletions

View File

@@ -1600,6 +1600,36 @@ export function createSSEStream(options: StreamOptions = {}) {
} else {
// Chat Completions: full sanitization pipeline
// Hardening: detect upstream returning empty choices array
// which breaks OpenAI-compatible clients (e.g. Copilot Chat)
if (Array.isArray(parsed.choices) && parsed.choices.length === 0) {
console.warn(
`[STREAM] Upstream returned empty choices array (${provider || "provider"}:${model || "unknown"}) — emitting error chunk`
);
const errorChunk = {
id: parsed.id || `omniroute-empty-choices-${Date.now()}`,
object: "chat.completion.chunk",
created: parsed.created || Math.floor(Date.now() / 1000),
model: parsed.model || model || "unknown",
choices: [
{
index: 0,
delta: {
role: "assistant",
content: "[OmniRoute] Upstream returned an empty response. Please retry.",
},
finish_reason: "stop",
},
],
};
output = `data: ${JSON.stringify(errorChunk)}\n`;
injectedUsage = true;
clientPayload = errorChunk;
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
continue;
}
// Detect reasoning alias before sanitization strips it
const hadReasoningAlias = !!(
parsed.choices?.[0]?.delta?.reasoning &&
@@ -2118,6 +2148,14 @@ export function createSSEStream(options: StreamOptions = {}) {
(a, b) => a.index - b.index
);
}
// Hardening: log empty assistant response after tool completion
// for observability — helps diagnose Copilot "Sorry, no response was returned"
if (passthroughHasToolCalls && !content.trim() && !reasoning.trim()) {
console.warn(
`[STREAM] Empty assistant response after tool_calls completion (${provider || "provider"}:${model || "unknown"}) — sessionId=${sessionId}`
);
}
const responseBody = {
choices: [
{

View File

@@ -0,0 +1,83 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { hasValuableContent } from "../../open-sse/utils/streamHelpers.ts";
import { FORMATS } from "../../open-sse/translator/formats.ts";
describe("empty-response hardening", () => {
describe("hasValuableContent edge cases", () => {
it("returns false for choices: [] (empty array)", () => {
const chunk = { choices: [] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns false for missing choices entirely", () => {
const chunk = { id: "test-123" };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns false for choices with null delta", () => {
const chunk = { choices: [{ delta: null, finish_reason: null }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns true for choices with finish_reason stop", () => {
const chunk = { choices: [{ delta: {}, finish_reason: "stop" }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
it("returns true for choices with finish_reason tool_calls", () => {
const chunk = { choices: [{ delta: {}, finish_reason: "tool_calls" }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
});
describe("empty choices array detection", () => {
it("should be detected by Array.isArray + length === 0", () => {
const parsed = { choices: [], id: "test", model: "gpt-4" };
assert.strictEqual(Array.isArray(parsed.choices), true);
assert.strictEqual(parsed.choices.length, 0);
});
it("should NOT trigger for choices with one element", () => {
const parsed = { choices: [{ delta: { content: "hi" } }], id: "test" };
assert.strictEqual(Array.isArray(parsed.choices), true);
assert.strictEqual(parsed.choices.length, 1);
});
it("should NOT trigger for choices with finish_reason only", () => {
const parsed = { choices: [{ delta: {}, finish_reason: "stop" }], id: "test" };
assert.strictEqual(Array.isArray(parsed.choices), true);
assert.strictEqual(parsed.choices.length, 1);
});
});
describe("tool completion empty response detection", () => {
it("detects empty content and reasoning after tool_calls", () => {
const passthroughHasToolCalls = true;
const content = "";
const reasoning = "";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), true);
});
it("does NOT trigger when content exists after tool_calls", () => {
const passthroughHasToolCalls = true;
const content = "Done!";
const reasoning = "";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false);
});
it("does NOT trigger when reasoning exists after tool_calls", () => {
const passthroughHasToolCalls = true;
const content = "";
const reasoning = "Let me think...";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false);
});
it("does NOT trigger when no tool_calls occurred", () => {
const passthroughHasToolCalls = false;
const content = "";
const reasoning = "";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false);
});
});
});

View File

@@ -1559,3 +1559,102 @@ test("createSSEStream passthrough mode decrements pending requests on failure",
`pending request count for ${modelKey} should be 0 after failure, got ${count}`
);
});
test("createSSEStream passthrough emits synthetic error chunk for empty choices array", async () => {
let onCompletePayload = null;
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl_empty",
object: "chat.completion.chunk",
created: 1,
model: "kimi-k2.6",
choices: [],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_empty",
object: "chat.completion.chunk",
created: 1,
model: "kimi-k2.6",
choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_empty",
object: "chat.completion.chunk",
created: 1,
model: "kimi-k2.6",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI,
provider: "opencode-go",
model: "kimi-k2.6",
body: { messages: [{ role: "user", content: "hello" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
);
// The empty choices chunk should have been replaced with a synthetic error chunk
assert.match(text, /\[OmniRoute\] Upstream returned an empty response/);
assert.match(text, /"finish_reason":"stop"/);
// Subsequent valid chunks should still be present
assert.match(text, /"content":"Hello"/);
assert.equal(onCompletePayload.status, 200);
});
test("createSSEStream passthrough logs empty response after tool_calls completion", async () => {
let onCompletePayload = null;
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl_tool_then_empty",
object: "chat.completion.chunk",
created: 1,
model: "gpt-5.5-xhigh",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_tc",
type: "function",
function: { name: "task_complete", arguments: '{}' },
},
],
},
},
],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_tool_then_empty",
object: "chat.completion.chunk",
created: 1,
model: "gpt-5.5-xhigh",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
})}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI,
provider: "codex",
model: "gpt-5.5-xhigh",
body: { messages: [{ role: "user", content: "do task" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
);
assert.match(text, /"finish_reason":"tool_calls"/);
assert.equal(onCompletePayload.status, 200);
assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls");
assert.equal(onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, "task_complete");
// Content should be null (empty) since no text was generated
assert.equal(onCompletePayload.responseBody.choices[0].message.content, null);
});