mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
fix(sse): provider-response summary used the client's format, not the provider's
providerPayloadCollector (dashboard "Provider Response" panel) was keyed on sourceFormat (the CLIENT's wire format) instead of targetFormat (the PROVIDER's — see createSSEStream's own @param doc: "targetFormat - Provider format", "sourceFormat - Client format"). Whenever a request translates between two different formats — e.g. a Responses-API client routed to a plain-OpenAI-chat-completions upstream, the common OpenClaw/opencode-zen shape — the reducer picked for sourceFormat could never recognize the provider's actual raw event shape, so it stayed stuck at its empty initial state. The dashboard's "Provider Response" panel showed a permanently empty `output: []` while "Client Response" (built from separately-accumulated state, unaffected by this bug) correctly showed full content — reading as if the two panels simply disagreed about the same request. Confirmed live via a wire-level pcap capture (scripts/sre/tcp-close- analyzer.py) cross-referenced against the dashboard log (1786032832181-1c6275): the actual response was complete and correct: this was purely a logging/summary bug, never a wire-format bug. Fix is mode-aware: TRANSLATE mode uses targetFormat (the provider's true format); PASSTHROUGH mode keeps sourceFormat, since passthrough has no separate provider/client format split — nothing gets translated there, and real passthrough callers (createPassthroughStreamWithLogger) don't even pass targetFormat. New regression test reproduces the exact live scenario (Responses-API source, OpenAI target, real chat.completion.chunk deltas) and asserts the provider summary reflects them — confirmed it fails with the old `sourceFormat`-keyed code (reproducing the live `output: []`-style symptom) and passes with the fix. Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
This commit is contained in:
@@ -776,7 +776,25 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// #9315: compute the summary live from every pushed chunk (not just the
|
||||
// ones that survive the storage cap below) so a long stream never shows a
|
||||
// stale/incomplete "provider response" in the dashboard.
|
||||
format: sourceFormat,
|
||||
//
|
||||
// Real bug: this was unconditionally `sourceFormat` (the CLIENT's wire
|
||||
// format — see this function's own @param doc above). In TRANSLATE mode
|
||||
// the chunks pushed here are the RAW PROVIDER response, whose format is
|
||||
// `targetFormat` (@param "Provider format (for translate mode)"), not
|
||||
// sourceFormat. Whenever a client's format differs from the provider's
|
||||
// (e.g. a Responses-API client routed to a plain-OpenAI-chat-completions
|
||||
// upstream — the OpenClaw/opencode-zen case that surfaced this live), the
|
||||
// reducer picked for `sourceFormat` could never recognize the provider's
|
||||
// actual event shape, so it never left its empty initial state — the
|
||||
// dashboard's "Provider Response" panel permanently showed
|
||||
// `output: []`/empty while "Client Response" (built from
|
||||
// separately-accumulated state, unaffected by this) correctly showed full
|
||||
// content, reading as if the two panels simply disagreed. PASSTHROUGH
|
||||
// mode has no separate provider/client format split — nothing gets
|
||||
// translated, so the provider's raw chunks genuinely ARE in sourceFormat
|
||||
// (and real passthrough callers, e.g. createPassthroughStreamWithLogger,
|
||||
// don't even pass targetFormat) — keep using sourceFormat there.
|
||||
format: mode === STREAM_MODE.TRANSLATE ? targetFormat : sourceFormat,
|
||||
fallbackModel: model,
|
||||
});
|
||||
const clientPayloadCollector = createStructuredSSECollector({
|
||||
|
||||
@@ -1098,6 +1098,63 @@ test("createSSEStream passthrough preserves Responses API events and completion
|
||||
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
|
||||
});
|
||||
|
||||
// Real bug found live (dashboard log id 1786032832181-1c6275, #9315 follow-up):
|
||||
// providerPayloadCollector was keyed on `sourceFormat` (the CLIENT's format)
|
||||
// instead of `targetFormat` (the PROVIDER's format — see createSSEStream's own
|
||||
// @param doc). A Responses-API client routed to a plain-OpenAI-chat-completions
|
||||
// upstream (exactly this OpenClaw/opencode-zen combo) fed the provider's real
|
||||
// chat.completion.chunk deltas into the Responses-API reducer, which never
|
||||
// recognizes them — so the dashboard's "Provider Response" panel stayed stuck
|
||||
// empty (`output: []`) forever while "Client Response" correctly showed full
|
||||
// content, reading as if the two panels disagreed about the same request.
|
||||
test("createSSEStream translate mode: providerPayload summary reflects the PROVIDER's format, not the client's", async () => {
|
||||
let onCompletePayload = null;
|
||||
await readTransformed(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "big-pickle",
|
||||
choices: [
|
||||
{ index: 0, delta: { role: "assistant", content: "Hello " }, finish_reason: null },
|
||||
],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "big-pickle",
|
||||
choices: [{ index: 0, delta: { content: "world" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})}\n\n`,
|
||||
`data: [DONE]\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "translate",
|
||||
// Client speaks Responses API; the upstream provider (opencode-zen-style)
|
||||
// speaks plain OpenAI chat-completions — exactly the OpenClaw combo that
|
||||
// surfaced this live.
|
||||
sourceFormat: FORMATS.OPENAI_RESPONSES,
|
||||
targetFormat: FORMATS.OPENAI,
|
||||
provider: "opencode-zen",
|
||||
model: "big-pickle",
|
||||
body: { input: "hi" },
|
||||
onComplete(payload) {
|
||||
onCompletePayload = payload;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const summary = onCompletePayload.providerPayload.summary;
|
||||
assert.ok(summary, "providerPayload.summary must not be null/undefined");
|
||||
// The bug's exact symptom: a Responses-API reducer fed chat-completion chunks
|
||||
// never recognizes them, so it stays at "no output" — assert the OPPOSITE.
|
||||
assert.equal(summary.object, "chat.completion");
|
||||
assert.equal(summary.choices?.[0]?.message?.content, "Hello world");
|
||||
assert.equal(summary.choices?.[0]?.finish_reason, "stop");
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for Responses clients", async () => {
|
||||
const text = await readTransformed(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user