From 13f7ebce5a5a10090d4196334cc27348d0ed9d43 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Wed, 13 May 2026 00:49:59 +0200 Subject: [PATCH] fix(stream): skip [DONE] terminator for Claude SSE clients (#2190) Integrated into release/v3.8.0 after syncing the contributor branch and validating tests/unit/stream-utils.test.ts locally. --- open-sse/utils/stream.ts | 21 +++++++++++-- tests/unit/stream-utils.test.ts | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index b75acecf51..a34d836173 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -550,6 +550,23 @@ export function createSSEStream(options: StreamOptions = {}) { ? clientResponseFormat === FORMATS.OPENAI_RESPONSES : sourceFormat === FORMATS.OPENAI_RESPONSES) === true; + // Clients whose SSE protocol terminates naturally on the last + // provider-shape event (not on a `data: [DONE]` line). Emitting + // `[DONE]` to these clients produces a parser error in the SDK and + // breaks follow-up turns (Capy/Anthropic SDK: text gets stuck in the + // "Thought" area; subsequent /v1/messages calls retry into a corrupt + // state). Skip the `[DONE]` for these formats. + const clientExpectsClaudeStream = + (mode === STREAM_MODE.PASSTHROUGH + ? clientResponseFormat === FORMATS.CLAUDE + : sourceFormat === FORMATS.CLAUDE) === true; + + // Single source of truth for the [DONE] decision, used at both emission + // sites below. Only OpenAI Chat Completions clients expect [DONE]; + // Responses API and Anthropic SSE terminate on their own protocol events + // (response.completed / message_stop respectively). + const shouldEmitDoneTerminator = !clientExpectsResponsesStream && !clientExpectsClaudeStream; + let buffer = ""; let usage: UsageTokenRecord | null = null; /** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */ @@ -1604,7 +1621,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (!doneSent) { await emitFinalSseMetadata(controller, usage); doneSent = true; - if (!clientExpectsResponsesStream) { + if (shouldEmitDoneTerminator) { clientPayloadCollector.push({ done: true }); const doneOutput = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(doneOutput); @@ -1800,7 +1817,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (!doneSent) { await emitFinalSseMetadata(controller, state?.usage as Record | null); doneSent = true; - if (!clientExpectsResponsesStream) { + if (shouldEmitDoneTerminator) { clientPayloadCollector.push({ done: true }); const doneOutput = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(doneOutput); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index e552551ebe..4c3d902115 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -561,6 +561,62 @@ test("createSSEStream passthrough injects a synthetic Claude text block for empt ); }); +test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", async () => { + const text = await readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_claude_done_gate", + type: "message", + role: "assistant", + model: "claude-sonnet-4", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 3, output_tokens: 0 }, + }, + })}\n\n`, + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Claude client stream" }, + })}\n\n`, + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 3 }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ + type: "message_stop", + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + clientResponseFormat: FORMATS.CLAUDE, + provider: "claude", + model: "claude-sonnet-4", + body: { + messages: [{ role: "user", content: "hello" }], + }, + } + ); + + assert.match(text, /event: message_stop/); + assert.match(text, /Claude client stream/); + assert.doesNotMatch(text, /\[DONE\]/); +}); + test("createSSEStream translate mode injects a synthetic Claude text block when OpenAI finishes empty", async () => { let onCompletePayload = null; const text = await readTransformed(