mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
fix(sse): harden against empty responses causing Copilot Chat failures (#3297)
Integrated into release/v3.8.13
This commit is contained in:
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