mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user