mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* fix(combo): failover when upstream SSE is truncated mid-lifecycle User log 1784230812441-bf3789: a combo target returned an SSE stream that carried bytes but never sent a recognised terminator (`data: [DONE]`, `message_stop`, `message_delta` with `stop_reason`, or a `finish_reason`) and never produced a single parseable SSE frame. The streaming quality validator's generic done-branch gate only checked `!sawAnyBytes`, so any byte at all — even unparseable garbage — passed the stream through. The combo did not fail over to the next target and the downstream SSE client hung waiting for events that never arrived. Rebuilt against the current release/v3.8.49 tip instead of the original branch diff: the original diff predates and deletes two fixes already merged to release — issue #7285 (`OpenAiLifecycleFlags` / `applyOpenAiLifecycleEvent`, the OpenAI-shape "truncated without finish_reason" failover branch) and issue #1382 (`SseLifecycleFlags .hasRealContent`, the Claude real-content vs. empty-content_block nuance). Both are preserved untouched here. Two new flags are tracked in parallel to that existing machinery instead of replacing it: * sawStructuredSSE — any parseable `event:` or `data:` frame was seen, even one carrying no recognised content (ping/metadata) — keeps the #3399/#3685 pass-through contract for those streams. * sawTerminator — a recognised terminator arrived: `data: [DONE]`, an OpenAI `finish_reason` (mirrors `openAi.hasTerminalMarker`), a Claude `message_stop`/`message_delta` with `stop_reason` (mirrors `sse.hasLifecycleEnd`), or a terminal `usage`-only chunk (new). The generic done-branch gate now requires neither flag to be true before marking the stream invalid, replacing the old `!sawAnyBytes` check (now dead and removed). The #7285 and #1382 branches are untouched. Tests added in tests/unit/validate-response-quality.test.ts (adapted from the original branch, same scenarios): 1. incomplete lifecycle (the bug) -> invalid 2. `[DONE]` only -> valid (regression guard for #3685) 3. `event: ping` only -> valid (regression guard for #3399) 4. OpenAI `finish_reason`-only chunk (no `[DONE]`) -> valid, isolates the new finish_reason check Full touched-area regression set verified green (51/51): the new tests plus combo-streaming-openai-no-finish-reason-7285, streaming-empty- content-block-1382, combo-quality-validator-reasoning, masked-200- exhaustion-fallback-6427, combo-streaming-empty-content-failover, combo-empty-content-failover-5085, combo-response-validation-failover, and combo-response-validation. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(combo): extract consumeSseLine + isTerminalUsageOnlyChunk helpers (complexity gate on parseAccumulatedSse) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(combo): move parseJsonRecord to module scope (finish complexity-gate compensation) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
import test from "node:test";
|
|
import assert from "assert";
|
|
import {
|
|
validateResponseQuality,
|
|
releaseQualityClone,
|
|
} from "../../open-sse/services/combo/validateQuality.ts";
|
|
|
|
function makeResponse(body: string, contentType = "text/plain") {
|
|
return {
|
|
headers: {
|
|
get: (name: string) => (name.toLowerCase() === "content-type" ? contentType : null),
|
|
},
|
|
clone: () => ({ text: async () => body }),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
test("returns valid=true for SSE with 'event:' lines", async () => {
|
|
const res = await validateResponseQuality(makeResponse("event: message\n\n"), false, {});
|
|
assert.strictEqual(res.valid, true);
|
|
});
|
|
|
|
test("returns valid=true for SSE with 'data:' lines", async () => {
|
|
const res = await validateResponseQuality(makeResponse('data: {"foo":"bar"}\n\n'), false, {});
|
|
assert.strictEqual(res.valid, true);
|
|
});
|
|
|
|
test("returns valid=false for non-JSON non-SSE text", async () => {
|
|
const res = await validateResponseQuality(makeResponse("Hello world"), false, {});
|
|
assert.strictEqual(res.valid, false);
|
|
});
|
|
|
|
test("returns valid=false for Responses API bodies with no output items", async () => {
|
|
const res = await validateResponseQuality(
|
|
makeResponse(JSON.stringify({ object: "response", status: "completed", output: [] }), "application/json"),
|
|
false,
|
|
{}
|
|
);
|
|
assert.strictEqual(res.valid, false);
|
|
});
|
|
|
|
test("returns valid=true for Responses API bodies with structural output", async () => {
|
|
const res = await validateResponseQuality(
|
|
makeResponse(
|
|
JSON.stringify({
|
|
object: "response",
|
|
status: "completed",
|
|
output: [{ type: "function_call", name: "lookup", arguments: "{}" }],
|
|
}),
|
|
"application/json"
|
|
),
|
|
false,
|
|
{}
|
|
);
|
|
assert.strictEqual(res.valid, true);
|
|
});
|
|
|
|
// ── releaseQualityClone (memory: release the abandoned quality-check tee branch) ──
|
|
|
|
test("releaseQualityClone is a no-op when the clone fell back to the original", () => {
|
|
const original = new Response("body");
|
|
// clone === original → must NOT touch the body (it's the response being streamed).
|
|
releaseQualityClone(original, original, { clonedResponse: original });
|
|
assert.strictEqual(original.bodyUsed, false, "original body must remain untouched");
|
|
});
|
|
|
|
test("releaseQualityClone cancels the discarded clonedResponse body", async () => {
|
|
const original = new Response("streamed to client");
|
|
const cloneBody = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("abandoned tee branch"));
|
|
},
|
|
});
|
|
const clonedResponse = new Response(cloneBody);
|
|
releaseQualityClone({} as Response, original, { clonedResponse });
|
|
// Give the microtask queue a tick for the cancel() promise to settle.
|
|
await Promise.resolve();
|
|
assert.ok(clonedResponse.body?.locked || cloneBody.locked === false);
|
|
// The original (client-facing) response is never disturbed.
|
|
assert.strictEqual(original.bodyUsed, false);
|
|
});
|
|
|
|
test("releaseQualityClone does not throw when there is no clonedResponse", () => {
|
|
const original = new Response("body");
|
|
assert.doesNotThrow(() => releaseQualityClone({} as Response, original, {}));
|
|
});
|
|
|
|
// ── Combo fallback silent-stop regression (#3399/#3685 + user log 1784230812441) ──
|
|
//
|
|
// Bug: combo streamed an upstream SSE response that carried bytes but never sent
|
|
// `data: [DONE]`, `message_stop`, or any `content_block_*`. The validator saw
|
|
// `sawAnyBytes === true` and passed the response through; OpenCode then hung
|
|
// waiting for the next event. Reported via local dashboard log
|
|
// `1784230812441-bf3789` (no public GitHub issue).
|
|
//
|
|
// Fix: the streaming validator now passes through only when it actually saw a
|
|
// recognised SSE terminator ([DONE], `message_stop`/`message_delta` with
|
|
// `stop_reason`, OpenAI `finish_reason`, terminal `usage`) OR structured SSE
|
|
// activity (parsed `data:` / `event:` frames) — tracked alongside (not instead
|
|
// of) the existing #7285/#1382 lifecycle machinery. Raw bytes that never
|
|
// produced a parseable event now correctly mark invalid.
|
|
|
|
function makeSseResponse(body: string): Response {
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(body));
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
|
|
test("streaming incomplete lifecycle: bytes with no terminator and no structured SSE → invalid", async () => {
|
|
// Garbage bytes that look like SSE prefix but never produce a complete
|
|
// `data:` line, no `event:`, no [DONE], no message_stop. This is the
|
|
// exact failure mode from log 1784230812441-bf3789.
|
|
const res = makeSseResponse(": keepalive\n\npartial da");
|
|
const verdict = await validateResponseQuality(res, true, {});
|
|
assert.strictEqual(verdict.valid, false);
|
|
assert.match(verdict.reason ?? "", /streaming/);
|
|
});
|
|
|
|
test("streaming [DONE] only (no content) → still valid (regression guard for #3685)", async () => {
|
|
const res = makeSseResponse("data: [DONE]\n\n");
|
|
const verdict = await validateResponseQuality(res, true, {});
|
|
assert.strictEqual(verdict.valid, true);
|
|
});
|
|
|
|
test("streaming event: ping only (no content, no terminator) → still valid (regression guard for #3399)", async () => {
|
|
// Some upstream providers emit periodic SSE pings for keepalive. The
|
|
// validator must continue to pass them through so the downstream SSE
|
|
// parser receives them rather than dropping the connection mid-stream.
|
|
const res = makeSseResponse(": ping - 2026-07-17\n\nevent: ping\ndata: {}\n\n");
|
|
const verdict = await validateResponseQuality(res, true, {});
|
|
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.
|
|
const res = makeSseResponse(
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'
|
|
);
|
|
const verdict = await validateResponseQuality(res, true, {});
|
|
assert.strictEqual(verdict.valid, true);
|
|
});
|