mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
fix(combo): default chaos SSE to comment-only for OpenAI-compatible clients (#10128)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * fix(combo): default chaos SSE to comment-only for OpenAI-compatible clients --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> Co-authored-by: herjarsa <herjarsa@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fbcd57db56
commit
a2e5bd1dfc
@@ -85,18 +85,29 @@ describe("runChaosPanel", () => {
|
||||
});
|
||||
|
||||
describe("serializeChaosPart", () => {
|
||||
it("emits a comment + omni-chaos-part event envelope", () => {
|
||||
it("emits a comment + omni-chaos-part event envelope when custom event is requested", () => {
|
||||
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
|
||||
const s = serializeChaosPart(part, false);
|
||||
const s = serializeChaosPart(part, false, true);
|
||||
expect(s).toContain("event: omni-chaos-part");
|
||||
expect(s).toContain('"type":"omni-chaos-part"');
|
||||
expect(s).toContain('"model":"a/gpt"');
|
||||
expect(s).toContain(": chaos 0 ok a/gpt");
|
||||
});
|
||||
|
||||
it("emits ONLY the SSE comment (no event/data) by default for OpenAI-compatible clients", () => {
|
||||
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
|
||||
const s = serializeChaosPart(part, false);
|
||||
// comment line kept (ignored by every SSE parser by spec)
|
||||
expect(s).toContain(": chaos 0 ok a/gpt");
|
||||
// NO custom event/data — those break openai-node / @ai-sdk validators
|
||||
expect(s).not.toContain("event: omni-chaos-part");
|
||||
expect(s).not.toContain('"type":"omni-chaos-part"');
|
||||
expect(s).not.toMatch(/^data:/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleChaosChat", () => {
|
||||
it("emits broadcast events + final OpenAI chunk", async () => {
|
||||
it("emits ONLY SSE comments (no custom event) by default + final OpenAI chunk", async () => {
|
||||
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
|
||||
const res = await handleChaosChat({
|
||||
body: { messages: [] },
|
||||
@@ -106,13 +117,27 @@ describe("handleChaosChat", () => {
|
||||
expect(res.headers.get("X-OmniRoute-Chaos")).toBe("true");
|
||||
expect(res.headers.get("X-OmniRoute-Chaos-Panel")).toBe("2");
|
||||
const body = await res.text();
|
||||
// each model gets a broadcast event
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
|
||||
// NO custom event by default — OpenAI-compatible parsers choke on it
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
|
||||
expect(body.match(/^: chaos /gm)?.length ?? 0).toBe(2);
|
||||
// final canonical chunk carries the primary answer
|
||||
expect(body).toContain("ans-b/opus");
|
||||
expect(body).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("emits omni-chaos-part events when stream_options.include_chaos_parts is set", async () => {
|
||||
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
|
||||
const res = await handleChaosChat({
|
||||
body: { messages: [], stream_options: { include_chaos_parts: true } },
|
||||
models: ["a/gpt", "b/opus"],
|
||||
handleSingleModel: handle,
|
||||
});
|
||||
const body = await res.text();
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
|
||||
expect(body).toContain("ans-b/opus");
|
||||
expect(body).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("degrades to a direct call when only one model", async () => {
|
||||
const handle = fakeHandle(async () => textResponse("solo"));
|
||||
const res = await handleChaosChat({
|
||||
@@ -138,8 +163,8 @@ describe("handleChaosChat", () => {
|
||||
// client learns via the error final chunk rather than a bare 503.
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
// each model gets a broadcast fail event
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
|
||||
// NO custom events by default (comments only), error conveyed via final chunk
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
|
||||
expect(body).toContain("All chaos panel models failed");
|
||||
expect(body).toContain("[DONE]");
|
||||
});
|
||||
|
||||
@@ -53,16 +53,28 @@ export type ChaosPart = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the SSE comment/event wrapper for one chaos panel part.
|
||||
* We emit a custom event name `omni-chaos-part` so a protocol-aware IDE can
|
||||
* split it out; non-aware clients reading OpenAI-style SSE will simply ignore
|
||||
* the unknown event and use the final `data:` chunk below.
|
||||
* Build the SSE wrapper for one chaos panel part.
|
||||
*
|
||||
* By DEFAULT only an SSE comment (`: chaos ...`) is emitted — comments are
|
||||
* ignored by every SSE parser per spec, so OpenAI-compatible clients
|
||||
* (openai-node, @ai-sdk/openai-compatible, …) never see a non-`choices`
|
||||
* `data:` payload and their schema validation cannot fail with an
|
||||
* `invalid_union` error.
|
||||
*
|
||||
* When `emitCustomEvent` is true (opt-in via
|
||||
* `stream_options.include_chaos_parts`), the custom event name
|
||||
* `omni-chaos-part` + metadata `data:` block is also emitted so a
|
||||
* protocol-aware IDE can split panels out.
|
||||
*
|
||||
* The part's text is NOT included in the metadata event — it arrives in the
|
||||
* final `data:` chunk for the primary model. This keeps each broadcast event
|
||||
* small (metadata-only) so SSE buffering stays predictable.
|
||||
*/
|
||||
export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
|
||||
export function serializeChaosPart(
|
||||
part: ChaosPart,
|
||||
isFinal: boolean,
|
||||
emitCustomEvent = false
|
||||
): string {
|
||||
const meta = {
|
||||
type: "omni-chaos-part",
|
||||
model: part.model,
|
||||
@@ -71,11 +83,11 @@ export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
|
||||
final: isFinal,
|
||||
...(part.error ? { error: part.error } : {}),
|
||||
};
|
||||
return (
|
||||
`: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n` +
|
||||
`event: omni-chaos-part\n` +
|
||||
`data: ${JSON.stringify(meta)}\n\n`
|
||||
);
|
||||
const comment = `: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n`;
|
||||
if (!emitCustomEvent) {
|
||||
return comment + "\n";
|
||||
}
|
||||
return comment + `event: omni-chaos-part\n` + `data: ${JSON.stringify(meta)}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,9 +342,11 @@ function concatSseText(sse: string): string {
|
||||
* `config.chaos.enabled` flag is set (the `auto/chaos` virtual combo).
|
||||
*
|
||||
* Returns a single Response whose body is an SSE stream:
|
||||
* - one `omni-chaos-part` event per panel model, enqueued PROGRESSIVELY as
|
||||
* each model lands (so the client starts receiving answers immediately,
|
||||
* without waiting for the whole panel to finish)
|
||||
* - one SSE comment (`: chaos N ...`) per panel model, enqueued
|
||||
* PROGRESSIVELY as each model lands (comments are ignored by every SSE
|
||||
* parser, so OpenAI-compatible clients see only the final chunk)
|
||||
* - when `stream_options.include_chaos_parts: true` is set, the per-panel
|
||||
* `omni-chaos-part` custom event is emitted instead of the bare comment
|
||||
* - a final `data:` OpenAI-style chunk carrying the primary model's answer
|
||||
* (so non-aware clients / IDEs still get a usable completion)
|
||||
* - a terminating `data: [DONE]`
|
||||
@@ -354,6 +368,14 @@ export async function handleChaosChat(opts: {
|
||||
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
|
||||
const hardTimeout = tuning?.panelHardTimeoutMs ?? CHAOS_DEFAULTS.panelHardTimeoutMs;
|
||||
const minPanel = tuning?.minPanel ?? CHAOS_DEFAULTS.minPanel;
|
||||
// Opt-in gate: only protocol-aware clients request the custom event. OpenAI
|
||||
// SDK validators choke on any `data:` payload without `choices`/`error`, so
|
||||
// the default MUST be comment-only output.
|
||||
const streamOptions = (body as Record<string, unknown> | null | undefined)?.stream_options;
|
||||
const emitCustomEvent =
|
||||
typeof streamOptions === "object" &&
|
||||
streamOptions !== null &&
|
||||
(streamOptions as Record<string, unknown>).include_chaos_parts === true;
|
||||
if (panel.length === 0) {
|
||||
return errorResponse(400, "Chaos combo has no models");
|
||||
}
|
||||
@@ -396,7 +418,7 @@ export async function handleChaosChat(opts: {
|
||||
hardTimeout,
|
||||
log,
|
||||
onResult: async (part) => {
|
||||
await safeEnqueue(serializeChaosPart(part, false));
|
||||
await safeEnqueue(serializeChaosPart(part, false, emitCustomEvent));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user