Class: sse-claude-client-no-terminal-when-upstream-omits-finish-reason
I diagnosed the issue against the real 9router/open-sse source, implemented the fix, and verified it with a 6-test regression suite (fails 4/6 before, passes 6/6 after). Full write-up written to ~/solution.md and reproduced here.
finish_reasonClass: sse-claude-client-no-terminal-when-upstream-omits-finish-reason
Env: node-22 · nextjs-16 · 9router · open-sse
Files: open-sse/translator/response/openai-to-claude.js, open-sse/utils/stream.js
Scope: translate mode only (do not touch passthrough data: [DONE]).
A Claude /v1/messages SSE client behind a chat-native OpenAI upstream gets
message_start + content blocks, then the upstream closes with no
finish_reason chunk. The client receives 0 message_delta, 0 message_stop
and hangs until timeout.
openaiToClaudeResponse pushes message_delta/message_stop only inside if (choice.finish_reason), so a missing finish chunk never reaches it.stream.js flush() requests the terminal via translateResponse(..., null, state), but openaiToClaudeResponse starts with if (!chunk || !chunk.choices?.[0]) return null; — so the null flush returns immediately and can never synthesize.target → openai → claude pivot dies on the first hop with a null chunk; the openai→claude hop is never invoked.state.finishReasonSent must not be reused: it's owned by claude-to-openai.js and openai-responses.js.
open-sse/translator/response/openai-to-claude.jsExtract the terminal block into a do-once helper and call it from both paths:
function emitClaudeTerminal(state, results, stopReason) {
if (state.claudeTerminalSent) return;
state.claudeTerminalSent = true;
stopThinkingBlock(state, results);
stopTextBlock(state, results);
for (const [idx, toolInfo] of state.toolCalls) {
const buffered = state.toolArgBuffers?.get(idx);
if (buffered) {
const sanitized = sanitizeToolArgs(toolInfo.name, buffered);
results.push({
type: "content_block_delta",
index: toolInfo.blockIndex,
delta: { type: "input_json_delta", partial_json: sanitized }
});
}
results.push({ type: "content_block_stop", index: toolInfo.blockIndex });
}
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
results.push({ type: "message_delta", delta: { stop_reason: stopReason }, usage: finalUsage });
results.push({ type: "message_stop" });
}
export function openaiToClaudeResponse(chunk, state) {
const results = [];
// Flush pass (chunk === null): synthesize when upstream closed without finish_reason.
// Gated on message_start so a zero-chunk upstream still yields an empty body.
if (!chunk) {
if (!state.messageStartSent) return null;
const stopReason = state.finishReason ? convertFinishReason(state.finishReason) : "end_turn";
emitClaudeTerminal(state, results, stopReason);
return results.length > 0 ? results : null;
}
if (!chunk.choices?.[0]) return null;
const choice = chunk.choices[0];
const delta = choice.delta;
// ... unchanged body ...
// Finish
if (choice.finish_reason) {
state.finishReason = choice.finish_reason;
emitClaudeTerminal(state, results, convertFinishReason(choice.finish_reason));
}
return results.length > 0 ? results : null;
}
Notes: gate on literal !chunk (not !chunk.choices?.[0]) so usage-only chunks stay ignored; claudeTerminalSent is a Claude-only latch; synthesized stop_reason is the observed reason else end_turn (never infer tool_use from half-streamed args).
open-sse/utils/stream.js — after the flushed block in translate-mode flush()// Claude clients: an upstream that closes without a finish_reason chunk (or whose
// own translator has no flush terminal — gemini/kiro/ollama/cursor/commandcode)
// leaves the client hanging. Ask the OpenAI→Claude translator for the owed terminal
// exactly once. Latched by state.claudeTerminalSent, so no-op on the direct OPENAI
// path. Gated on message_start: no start means nothing to terminate.
if (sourceFormat === FORMATS.CLAUDE && state && !state.claudeTerminalSent && state.messageStartSent) {
const claudeTerminal = translateResponse(FORMATS.OPENAI, FORMATS.CLAUDE, null, state);
if (claudeTerminal?.length > 0) {
for (const item of claudeTerminal) {
if (item === null || item === undefined) continue;
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
}
}
}
Six tests driven through the real createSSEStream (OpenAI→Claude), run with a
loader mapping @/ to src/:
>=1 message_delta, exactly 1 message_stop, content_block_stop before message_delta, stop_reason === "end_turn".finish_reason control → exactly one message_delta/message_stop.tool_use args flushed through sanitize path (Read.limit "5" → 5).message_start.message_start and latches exact-once.Run:
node --test --experimental-loader ./loader.mjs ./regression.test.mjs
Results (node v22.23.2):
| Build | 1 | 2 | 3 | 4 | 5 | 6 | Pass |
|---|---|---|---|---|---|---|---|
| Before fix | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | 2/6 |
| After fix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 6/6 |
# tests 6
# pass 6
# fail 0
data: [DONE] emission (#142 OpenClaw workaround) is untouched.state.finishReasonSent.tool_use for synthesized stops.message_start ⇒ no terminal synthesized.# Evidence - Problem class: sse-claude-client-no-terminal-when-upstream-omits-finish-reason - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T18:13:28.082Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: An Anthropic-format SSE client (Claude /v1/messages shape) behind a chat-native OpenAI upstream that streams content then closes WITHOUT ever sending a finish_reason chunk receives ZERO terminal events: 0 message_delta, 0 message_stop. The client hangs until timeout. ROOT CAUSE: openaiToClaudeResponse (open-sse/translator/response/openai-to-claude.js) emits message_delta/message_stop only inside if(choice.finish_reason), and the stream.js translate-mode flush sentinel is gated on sourceFormat===FORMATS.OPENAI, so the Claude-client case falls through completely; the translator also returns null for the null-chunk flush call (if(!chunk||!chunk.choices?.[0]) return null), so flush() never gets a chance to synthesise anything for that direction. VERIFIED FIX (9router commit 0b7a2651, judge PASS 673f8f2d, 6-test regression suite): (1) Extract the finish-block terminal emission into a shared emitClaudeTerminal helper in openai-to-claude.js, called from BOTH the choice.finish_reason path AND the chunk=null flush call, latched by a Claude-specific state.claudeTerminalSent flag for exact-once. Do NOT reuse the shared initState finishReasonSent flag \u2014 it is owned by other translators' directions (claude-to-openai, openai-responses) and reusing it couples unrelated paths. (2) In open-sse/utils/stream.js translate-mode flush(), after the existing translateResponse(targetFormat, sourceFormat, null, state) call, add a clause for sourceFormat===FORMATS.CLAUDE that asks the translator for the owed terminal when none was emitted (model on the existing OPENAI_RESPONSES formatIncompleteOpenAIResponsesStreamFailure synthesis block). This also covers Claude clients behind upstreams whose own translator has no flush terminal (gemini/kiro/ollama/cursor/commandcode). (3) Gate synthesis on state.messageStartSent: if no message_start ever reached the client there is nothing to terminate \u2014 a zero-chunk upstream must still yield an empty body. (4) Synthesised stop_reason = state.finishReason if set, else end_turn (conservative: do NOT infer tool_use from open tool calls \u2014 the args may be half-streamed). (5) Scope: translate mode ONLY. Passthrough-mode emission of data: [DONE] to Claude clients is a deliberate OpenClaw-hang workaround (#142) \u2014 do not gate or remove it. Tests: drive the OPENAI->CLAUDE path through createSSEStream with a chat SSE body lacking any finish chunk; assert >=1 message_delta(stop_reason), exactly 1 message_stop, message_delta before message_stop after content_block_stop, partial tool_use args flushed via existing sanitize path, and a with-finish_reason control asserting exact-once.", "environment": "node-22 nextjs-16 9router open-sse", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sse-claude-client-no-terminal-when-upstream-omits-finish-reason", "provider": "openrouter", "solved_at": "2026-09-14T18:13:28.082Z", "version": ""}I diagnosed the issue against the real 9router/open-sse source, implemented the fix, and verified it with a 6-test regression suite (fails 4/6 before, passes 6/6 after). Full write-up written to ~/solution.md and reproduced here.
finish_reasonClass: sse-claude-client-no-terminal-when-upstream-omits-finish-reason
Env: node-22 · nextjs-16 · 9router · open-sse
Files: open-sse/translator/response/openai-to-claude.js, open-sse/utils/stream.js
Scope: translate mode only (do not touch passthrough data: [DONE]).
A Claude /v1/messages SSE client behind a chat-native OpenAI upstream gets
message_start + content blocks, then the upstream closes with no
finish_reason chunk. The client receives 0 message_delta, 0 message_stop
and hangs until timeout.
openaiToClaudeResponse pushes message_delta/message_stop only inside if (choice.finish_reason), so a missing finish chunk never reaches it.stream.js flush() requests the terminal via translateResponse(..., null, state), but openaiToClaudeResponse starts with if (!chunk || !chunk.choices?.[0]) return null; — so the null flush returns immediately and can never synthesize.target → openai → claude pivot dies on the first hop with a null chunk; the openai→claude hop is never invoked.state.finishReasonSent must not be reused: it's owned by claude-to-openai.js and openai-responses.js.
open-sse/translator/response/openai-to-claude.jsExtract the terminal block into a do-once helper and call it from both paths:
function emitClaudeTerminal(state, results, stopReason) {
if (state.claudeTerminalSent) return;
state.claudeTerminalSent = true;
stopThinkingBlock(state, results);
stopTextBlock(state, results);
for (const [idx, toolInfo] of state.toolCalls) {
const buffered = state.toolArgBuffers?.get(idx);
if (buffered) {
const sanitized = sanitizeToolArgs(toolInfo.name, buffered);
results.push({
type: "content_block_delta",
index: toolInfo.blockIndex,
delta: { type: "input_json_delta", partial_json: sanitized }
});
}
results.push({ type: "content_block_stop", index: toolInfo.blockIndex });
}
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
results.push({ type: "message_delta", delta: { stop_reason: stopReason }, usage: finalUsage });
results.push({ type: "message_stop" });
}
export function openaiToClaudeResponse(chunk, state) {
const results = [];
// Flush pass (chunk === null): synthesize when upstream closed without finish_reason.
// Gated on message_start so a zero-chunk upstream still yields an empty body.
if (!chunk) {
if (!state.messageStartSent) return null;
const stopReason = state.finishReason ? convertFinishReason(state.finishReason) : "end_turn";
emitClaudeTerminal(state, results, stopReason);
return results.length > 0 ? results : null;
}
if (!chunk.choices?.[0]) return null;
const choice = chunk.choices[0];
const delta = choice.delta;
// ... unchanged body ...
// Finish
if (choice.finish_reason) {
state.finishReason = choice.finish_reason;
emitClaudeTerminal(state, results, convertFinishReason(choice.finish_reason));
}
return results.length > 0 ? results : null;
}
Notes: gate on literal !chunk (not !chunk.choices?.[0]) so usage-only chunks stay ignored; claudeTerminalSent is a Claude-only latch; synthesized stop_reason is the observed reason else end_turn (never infer tool_use from half-streamed args).
open-sse/utils/stream.js — after the flushed block in translate-mode flush()// Claude clients: an upstream that closes without a finish_reason chunk (or whose
// own translator has no flush terminal — gemini/kiro/ollama/cursor/commandcode)
// leaves the client hanging. Ask the OpenAI→Claude translator for the owed terminal
// exactly once. Latched by state.claudeTerminalSent, so no-op on the direct OPENAI
// path. Gated on message_start: no start means nothing to terminate.
if (sourceFormat === FORMATS.CLAUDE && state && !state.claudeTerminalSent && state.messageStartSent) {
const claudeTerminal = translateResponse(FORMATS.OPENAI, FORMATS.CLAUDE, null, state);
if (claudeTerminal?.length > 0) {
for (const item of claudeTerminal) {
if (item === null || item === undefined) continue;
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
}
}
}
Six tests driven through the real createSSEStream (OpenAI→Claude), run with a
loader mapping @/ to src/:
>=1 message_delta, exactly 1 message_stop, content_block_stop before message_delta, stop_reason === "end_turn".finish_reason control → exactly one message_delta/message_stop.tool_use args flushed through sanitize path (Read.limit "5" → 5).message_start.message_start and latches exact-once.Run:
node --test --experimental-loader ./loader.mjs ./regression.test.mjs
Results (node v22.23.2):
| Build | 1 | 2 | 3 | 4 | 5 | 6 | Pass |
|---|---|---|---|---|---|---|---|
| Before fix | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | 2/6 |
| After fix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 6/6 |
# tests 6
# pass 6
# fail 0
data: [DONE] emission (#142 OpenClaw workaround) is untouched.state.finishReasonSent.tool_use for synthesized stops.message_start ⇒ no terminal synthesized.# Evidence - Problem class: sse-claude-client-no-terminal-when-upstream-omits-finish-reason - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T18:13:28.082Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: An Anthropic-format SSE client (Claude /v1/messages shape) behind a chat-native OpenAI upstream that streams content then closes WITHOUT ever sending a finish_reason chunk receives ZERO terminal events: 0 message_delta, 0 message_stop. The client hangs until timeout. ROOT CAUSE: openaiToClaudeResponse (open-sse/translator/response/openai-to-claude.js) emits message_delta/message_stop only inside if(choice.finish_reason), and the stream.js translate-mode flush sentinel is gated on sourceFormat===FORMATS.OPENAI, so the Claude-client case falls through completely; the translator also returns null for the null-chunk flush call (if(!chunk||!chunk.choices?.[0]) return null), so flush() never gets a chance to synthesise anything for that direction. VERIFIED FIX (9router commit 0b7a2651, judge PASS 673f8f2d, 6-test regression suite): (1) Extract the finish-block terminal emission into a shared emitClaudeTerminal helper in openai-to-claude.js, called from BOTH the choice.finish_reason path AND the chunk=null flush call, latched by a Claude-specific state.claudeTerminalSent flag for exact-once. Do NOT reuse the shared initState finishReasonSent flag \u2014 it is owned by other translators' directions (claude-to-openai, openai-responses) and reusing it couples unrelated paths. (2) In open-sse/utils/stream.js translate-mode flush(), after the existing translateResponse(targetFormat, sourceFormat, null, state) call, add a clause for sourceFormat===FORMATS.CLAUDE that asks the translator for the owed terminal when none was emitted (model on the existing OPENAI_RESPONSES formatIncompleteOpenAIResponsesStreamFailure synthesis block). This also covers Claude clients behind upstreams whose own translator has no flush terminal (gemini/kiro/ollama/cursor/commandcode). (3) Gate synthesis on state.messageStartSent: if no message_start ever reached the client there is nothing to terminate \u2014 a zero-chunk upstream must still yield an empty body. (4) Synthesised stop_reason = state.finishReason if set, else end_turn (conservative: do NOT infer tool_use from open tool calls \u2014 the args may be half-streamed). (5) Scope: translate mode ONLY. Passthrough-mode emission of data: [DONE] to Claude clients is a deliberate OpenClaw-hang workaround (#142) \u2014 do not gate or remove it. Tests: drive the OPENAI->CLAUDE path through createSSEStream with a chat SSE body lacking any finish chunk; assert >=1 message_delta(stop_reason), exactly 1 message_stop, message_delta before message_stop after content_block_stop, partial tool_use args flushed via existing sanitize path, and a with-finish_reason control asserting exact-once.", "environment": "node-22 nextjs-16 9router open-sse", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sse-claude-client-no-terminal-when-upstream-omits-finish-reason", "provider": "openrouter", "solved_at": "2026-09-14T18:13:28.082Z", "version": ""}