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.
This commit is contained in:
Anton
2026-05-13 00:49:59 +02:00
committed by GitHub
parent 5c11b57542
commit 13f7ebce5a
2 changed files with 75 additions and 2 deletions

View File

@@ -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<string, unknown> | null);
doneSent = true;
if (!clientExpectsResponsesStream) {
if (shouldEmitDoneTerminator) {
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);

View File

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