mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
This commit is contained in:
committed by
GitHub
parent
6cad24eec0
commit
9ced2e99df
1
changelog.d/fixes/7503-empty-choices-stream-readiness.md
Normal file
1
changelog.d/fixes/7503-empty-choices-stream-readiness.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): stop stream readiness from treating a choices-less mid-stream error frame as a successful stream, so combo can fail over instead of returning zero `choices` (#7503)
|
||||
@@ -81,11 +81,40 @@ function getPayloadType(payload: unknown, eventType = ""): string {
|
||||
return typeof type === "string" ? type : eventType;
|
||||
}
|
||||
|
||||
// Keys that indicate a frame carries (or is starting to carry) actual model
|
||||
// output — as opposed to a bare `{error:{...}}` frame with no output signal
|
||||
// at all. A stream that only ever emits error-only frames (e.g. a CLI
|
||||
// passthrough executor's mid-stream spawn failure, #7503) must NOT be
|
||||
// classified as "ready" — treating it as ready lets the malformed frame
|
||||
// reach the client as a fake 200 success and blocks combo fallback to the
|
||||
// next candidate.
|
||||
const CONTENT_BEARING_KEYS = [
|
||||
"choices",
|
||||
"candidates",
|
||||
"content_block",
|
||||
"delta",
|
||||
"output",
|
||||
"response",
|
||||
"parts",
|
||||
"tool_calls",
|
||||
"tool_use",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
];
|
||||
|
||||
function isErrorOnlyStructuredPayload(payload: Record<string, unknown>): boolean {
|
||||
if (!("error" in payload)) return false;
|
||||
return !CONTENT_BEARING_KEYS.some((key) => key in payload);
|
||||
}
|
||||
|
||||
function hasNonPingStructuredPayload(payload: unknown, eventType = ""): boolean {
|
||||
const type = getPayloadType(payload, eventType);
|
||||
if (isPingEventType(eventType) || isPingEventType(type)) return false;
|
||||
if (Array.isArray(payload)) return payload.length > 0;
|
||||
if (isRecord(payload)) return Object.keys(payload).length > 0;
|
||||
if (isRecord(payload)) {
|
||||
if (Object.keys(payload).length === 0) return false;
|
||||
return !isErrorOnlyStructuredPayload(payload);
|
||||
}
|
||||
return payload !== null && payload !== undefined;
|
||||
}
|
||||
|
||||
|
||||
57
tests/unit/repro-7503-no-choices.test.ts
Normal file
57
tests/unit/repro-7503-no-choices.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { hasStreamReadinessSignal } = await import("@omniroute/open-sse/utils/streamReadiness");
|
||||
const { buildErrorBody } = await import("@omniroute/open-sse/utils/error");
|
||||
const { AuggieExecutor } = await import("@omniroute/open-sse/executors/auggie");
|
||||
|
||||
test("hasStreamReadinessSignal wrongly reports readiness for a choices-less mid-stream error frame", () => {
|
||||
const errorBody = buildErrorBody(502, "Auggie CLI not found: auggie.cmd");
|
||||
assert.equal("choices" in errorBody, false, "sanity check: buildErrorBody() output really has no choices key");
|
||||
|
||||
const sse = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`;
|
||||
const ready = hasStreamReadinessSignal(sse);
|
||||
|
||||
assert.equal(
|
||||
ready,
|
||||
false,
|
||||
"a stream carrying only a choices-less error frame should NOT be reported as 'ready' " +
|
||||
"(successful) — it gives the combo router no signal to fail over to the next candidate, " +
|
||||
"and the client accumulates zero `choices` for the whole request, which is exactly what " +
|
||||
"makes strict OpenAI-SDK clients (VS Code Copilot, Cline) throw 'Response contained no choices.'"
|
||||
);
|
||||
});
|
||||
|
||||
test("AuggieExecutor's real streaming error path emits an SSE body with zero populated `choices` (end-to-end)", async () => {
|
||||
const executor = new AuggieExecutor();
|
||||
const previousBin = process.env.AUGGIE_BIN;
|
||||
process.env.AUGGIE_BIN = "/definitely/does/not/exist/auggie-xyz-7503";
|
||||
try {
|
||||
const { response } = await executor.execute({
|
||||
model: "claude-sonnet-4.6",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: true,
|
||||
credentials: {} as never,
|
||||
signal: null,
|
||||
log: { info: () => {}, debug: () => {}, warn: () => {} },
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const dataLines = text.split("\n").filter((l) => l.startsWith("data: "))
|
||||
.map((l) => l.slice("data: ".length).trim()).filter((d) => d !== "[DONE]");
|
||||
assert.ok(dataLines.length > 0, "expected at least one SSE data event");
|
||||
|
||||
const anyPopulatedChoices = dataLines.some((d) => {
|
||||
const parsed = JSON.parse(d);
|
||||
return Array.isArray(parsed.choices) && parsed.choices.length > 0 &&
|
||||
(parsed.choices[0]?.delta?.content || parsed.choices[0]?.message?.content);
|
||||
});
|
||||
|
||||
assert.equal(anyPopulatedChoices, false,
|
||||
"AuggieExecutor's CLI-failure SSE stream never carries a populated `choices` entry — " +
|
||||
"it is only a choices-less {error:...} frame + [DONE]");
|
||||
} finally {
|
||||
if (previousBin === undefined) delete process.env.AUGGIE_BIN;
|
||||
else process.env.AUGGIE_BIN = previousBin;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user