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:
Markus Hartung
2026-08-06 18:27:27 +02:00
parent 7b1f3528e6
commit 032a3d2f76
2 changed files with 76 additions and 1 deletions

View File

@@ -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({

View File

@@ -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(
[