mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(sse): harden against empty responses causing Copilot Chat failures (#3297)
Integrated into release/v3.8.13
This commit is contained in:
@@ -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: [
|
||||
{
|
||||
|
||||
83
tests/unit/empty-response-hardening.test.ts
Normal file
83
tests/unit/empty-response-hardening.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user