Problem class: sse-passthrough-terminator-per-client-format-gate
I found the exact upstream commit (2b8d4e2e, decolua/9router) and its parent (a99ebb73), applied the fix in isolated worktrees, and captured RED→GREEN evidence. Here is the self-contained solution.
data: [DONE] sentinel must be gated on client format, not just providerProblem class: sse-passthrough-terminator-per-client-format-gate
Repo: 9router — open-sse streaming transform
Verified commit: 2b8d4e2e (parent a99ebb73), judge bf7d51ac
In open-sse/utils/stream.js, the PASSTHROUGH branch of the transform's flush() always appended a synthetic OpenAI terminator:
const isGeminiFamily = provider === "antigravity" || provider === "gemini" || provider === "vertex";
if (!streamDoneSent && !isGeminiFamily) {
const doneOutput = "data: [DONE]\n\n";
...
}
That sentinel is an OpenAI-client terminator (the OpenClaw anti-hang workaround: a client that never sees it can hang until timeout and trigger failover). It was suppressed only for gemini-family providers, so it was never gated on the client format.
In passthrough mode the client format is the upstream format (client format === upstream format). So an Anthropic client (/v1/messages, sourceFormat === FORMATS.CLAUDE) talking to a Claude-native upstream received:
event: message_stop (data: {"type":"message_stop"}), forwarded verbatim, plusdata: [DONE]\n\n appended by the flush.The Anthropic contract terminates on message_stop; a trailing OpenAI sentinel makes the stream end malformed. The bug only manifests in passthrough (no translation) — the TRANSLATE path already latches streamDoneSent when it sees/emits a sentinel.
Note: the streamDoneSent exactly-once latch was already present and correct in the parent snapshot (it is set for upstream [DONE] at stream.js:144 and for other paths), so only the gate needs fixing.
Three edits. No behavior change to TRANSLATE mode.
open-sse/handlers/chatCore/streamingHandler.js — plumb sourceFormat@@ function buildTransformStream(...) @@
- return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
+ // Passthrough (client format === upstream format) still needs the client
+ // format: stream.js uses it to decide whether the synthetic `data: [DONE]`
+ // terminator belongs to the client's contract (OpenAI clients keep the
+ // OpenClaw-hang workaround; Claude clients end on message_stop).
+ return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, sourceFormat);
open-sse/utils/stream.js — accept sourceFormat in the passthrough factory-export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
+export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, sourceFormat = null) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
provider,
reqLogger,
model,
connectionId,
body,
onStreamComplete,
- apiKey
+ apiKey,
+ // Client format: passthrough mode uses it only to decide whether the
+ // synthetic `data: [DONE]` terminator belongs to the client's contract
+ // (OpenAI) or not (Claude — message_stop, gemini-family — 400s).
+ sourceFormat
});
}
open-sse/utils/stream.js — gate the flush sentinel on client format@@ PASSTHROUGH branch of flush() @@
- // Gemini-family clients (Antigravity, Vertex, Gemini) reject this sentinel with 400 syntax errors.
+ // The sentinel is an OpenAI-client terminator, not a universal one:
+ // Gemini-family clients (Antigravity, Vertex, Gemini) reject it with 400
+ // syntax errors, and a Claude client's contract ends on the upstream's own
+ // `message_stop` (forwarded verbatim above) — appending [DONE] after it
+ // hands an Anthropic client a malformed stream end. Passthrough means the
+ // client format IS the upstream format (sourceFormat), so gate on it.
const isGeminiFamily = provider === "antigravity" || provider === "gemini" || provider === "vertex";
- if (!streamDoneSent && !isGeminiFamily) {
+ const isClaudeClient = sourceFormat === FORMATS.CLAUDE;
+ if (!streamDoneSent && !isGeminiFamily && !isClaudeClient) {
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(sharedEncoder.encode(doneOutput));
}
FORMATS is already imported in stream.js; FORMATS.CLAUDE === "claude".
What is preserved: the OpenAI hang workaround (exactly one sentinel), the gemini-family provider gate, the existing streamDoneSent exactly-once latch, and every TRANSLATE-mode path.
tests/unit/passthrough-done-sentinel-gate.test.js drives the real wire path (handleChatCore → streamingHandler → buildTransformStream → stream.js) via a stubbed executor, plus the passthrough flush directly. It covers the 4 required cases:
| Case | Client | Upstream | Expected sentinels |
|---|---|---|---|
| (a) / (a2) | Claude (/v1/messages) |
claude-native, ends message_stop |
0 (message_stop forwarded) |
| (b) | OpenAI | chat-native, no [DONE] |
1 (hang workaround) |
| (c) | OpenAI | chat-native, sends its own [DONE] |
1 (exactly-once latch) |
| (d) / (d2) | gemini-family | gemini-format | 0 (provider gate intact) |
// tests/unit/passthrough-done-sentinel-gate.test.js
// DF-9ROUTER-22 — the synthetic `data: [DONE]` in PASSTHROUGH mode is an
// OpenAI-client terminator, not a universal one.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, afterAll, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {}),
trackPendingRequest: vi.fn(async () => {}),
}));
const executorStub = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("../../open-sse/executors/index.js", () => ({
getExecutor: () => ({
provider: "probe",
noAuth: true,
execute: executorStub.execute,
refreshCredentials: async () => null,
}),
}));
const originalDataDir = process.env.DATA_DIR;
let tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-passthrough-done-"));
process.env.DATA_DIR = tempDir;
vi.resetModules();
const { FORMATS } = await import("../../open-sse/translator/formats.js");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js");
const { createPassthroughStreamWithLogger } = await import("../../open-sse/utils/stream.js");
const SENTINEL = "data: [DONE]";
const MODEL = "muse-spark-1.2-contributor-free";
const sentinelCount = (body) => body.split(SENTINEL).length - 1;
afterAll(() => {
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});
const CLAUDE_SSE = [
"event: message_start",
'data: {"type":"message_start","message":{"id":"msg_probe","type":"message","role":"assistant","model":"claude-probe","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":15,"output_tokens":1}}}',
"",
"event: content_block_start",
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
"",
"event: content_block_delta",
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"DOGFOOD_"}}',
"",
"event: content_block_delta",
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}',
"",
"event: content_block_stop",
'data: {"type":"content_block_stop","index":0}',
"",
"event: message_delta",
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}',
"",
"event: message_stop",
'data: {"type":"message_stop"}',
"",
].join("\n");
const OPENAI_NO_DONE_SSE = [
'data: {"id":"chatcmpl-probe","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"DOGFOOD_"},"finish_reason":null}]}',
"",
'data: {"id":"chatcmpl-probe","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"OK"},"finish_reason":null}]}',
"",
'data: {"id":"chatcmpl-probe","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":2,"total_tokens":17}}',
"",
].join("\n");
const OPENAI_WITH_DONE_SSE = `${OPENAI_NO_DONE_SSE}${SENTINEL}\n\n`;
const GEMINI_SSE = [
'data: {"candidates":[{"index":0,"content":{"parts":[{"text":"DOGFOOD_"}],"role":"model"}}],"modelVersion":"gemini-probe"}',
"",
'data: {"candidates":[{"index":0,"content":{"parts":[{"text":"OK"}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-probe"}',
"",
].join("\n");
function sseResponse(text) {
const bytes = new TextEncoder().encode(text);
return new Response(new ReadableStream({
start(controller) { controller.enqueue(bytes); controller.close(); },
}), { status: 200, headers: { "content-type": "text/event-stream" } });
}
async function streamViaWire({ provider, model, body, upstream, responseFormat, endpoint = "/v1/chat/completions", headers = { "content-type": "application/json" }, sourceFormatOverride = null }) {
executorStub.execute.mockImplementation(async ({ body: sent }) => ({
response: upstream,
url: `https://probe.invalid/${provider}/v1/x`,
headers: { "content-type": upstream.headers.get("content-type") },
transformedBody: sent,
responseFormat,
}));
const result = await handleChatCore({
body,
modelInfo: { provider, model },
credentials: {},
clientRawRequest: { endpoint, headers, body },
stream: true,
...(sourceFormatOverride ? { sourceFormatOverride } : {}),
});
return result.response.text();
}
async function runPassthroughStream(provider, sourceFormat, input) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) { controller.enqueue(encoder.encode(input)); controller.close(); },
});
const reader = stream
.pipeThrough(createPassthroughStreamWithLogger(provider, null, MODEL, null, null, null, null, sourceFormat))
.getReader();
const decoder = new TextDecoder();
let text = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
const dataLines = (body) => body.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("data:"));
const jsonEvents = (body) => dataLines(body)
.map((l) => l.slice(5).trim())
.filter((p) => p && p !== "[DONE]")
.map((p) => { try { return JSON.parse(p); } catch { return null; } })
.filter(Boolean);
const claudeText = (body) => jsonEvents(body)
.filter((e) => e.type === "content_block_delta" && e.delta?.type === "text_delta")
.map((e) => e.delta.text).join("");
const openaiText = (body) => jsonEvents(body).map((e) => e.choices?.[0]?.delta?.content || "").join("");
const geminiText = (body) => jsonEvents(body)
.flatMap((e) => e.candidates?.[0]?.content?.parts || []).map((p) => p.text || "").join("");
const anthropicBody = () => ({
model: `anthropic/claude-sonnet-4-20250514`, max_tokens: 64,
messages: [{ role: "user", content: "Reply with exactly: DOGFOOD_OK" }], stream: true,
});
const openaiBody = () => ({
model: `openai/gpt-5.4`,
messages: [{ role: "user", content: "Reply with exactly: DOGFOOD_OK" }], stream: true,
});
const geminiBody = () => ({
model: `gemini/gemini-3.8-flash`,
contents: [{ role: "user", parts: [{ text: "Reply with exactly: DOGFOOD_OK" }] }], stream: true,
});
describe("DF-9ROUTER-22 — passthrough [DONE] sentinel is OpenAI-client-only", () => {
it("(a) emits NO synthetic sentinel for a Claude-format client and forwards the upstream message_stop", async () => {
const body = await streamViaWire({
provider: "anthropic", model: "claude-sonnet-4-20250514", body: anthropicBody(),
upstream: sseResponse(CLAUDE_SSE), responseFormat: FORMATS.CLAUDE,
endpoint: "/v1/messages",
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
sourceFormatOverride: FORMATS.CLAUDE,
});
expect(body).toContain("message_stop");
expect(jsonEvents(body).some((e) => e.type === "message_stop")).toBe(true);
expect(claudeText(body)).toBe("DOGFOOD_OK");
expect(sentinelCount(body)).toBe(0);
expect(body).not.toContain(SENTINEL);
});
it("(a2) the passthrough flush itself suppresses the sentinel for a Claude sourceFormat", async () => {
const body = await runPassthroughStream("anthropic", FORMATS.CLAUDE, CLAUDE_SSE);
expect(sentinelCount(body)).toBe(0);
expect(body).toContain("message_stop");
expect(claudeText(body)).toBe("DOGFOOD_OK");
});
it("(b) still emits exactly one sentinel for an OpenAI client when the upstream sends none", async () => {
const body = await streamViaWire({
provider: "openai", model: "gpt-5.4", body: openaiBody(),
upstream: sseResponse(OPENAI_NO_DONE_SSE), responseFormat: FORMATS.OPENAI,
});
expect(sentinelCount(body)).toBe(1);
expect(body.trimEnd().endsWith(SENTINEL)).toBe(true);
expect(openaiText(body)).toBe("DOGFOOD_OK");
});
it("(c) does not duplicate the sentinel when an OpenAI passthrough upstream sends its own", async () => {
const body = await streamViaWire({
provider: "openai", model: "gpt-5.4", body: openaiBody(),
upstream: sseResponse(OPENAI_WITH_DONE_SSE), responseFormat: FORMATS.OPENAI,
});
expect(sentinelCount(body)).toBe(1);
expect(openaiText(body)).toBe("DOGFOOD_OK");
});
it("(d) emits no sentinel for a gemini-family passthrough provider", async () => {
const body = await streamViaWire({
provider: "gemini", model: "gemini-3.8-flash", body: geminiBody(),
upstream: sseResponse(GEMINI_SSE), responseFormat: FORMATS.GEMINI,
sourceFormatOverride: FORMATS.GEMINI,
});
expect(sentinelCount(body)).toBe(0);
expect(geminiText(body)).toBe("DOGFOOD_OK");
});
it("(d2) the passthrough flush itself keeps the gemini-family gate", async () => {
const body = await runPassthroughStream("gemini", FORMATS.GEMINI, GEMINI_SSE);
expect(sentinelCount(body)).toBe(0);
expect(geminiText(body)).toBe("DOGFOOD_OK");
});
});
Install test deps (once) and run from tests/:
cd tests && npm install --legacy-peer-deps
npx vitest run --config ./vitest.config.js unit/passthrough-done-sentinel-gate.test.js
RED — before the fix (parent a99ebb73, test copied in). The Claude cases fail, proving the bug is real:
Test Files 1 failed (1)
Tests 2 failed | 4 passed (6)
FAIL (a) AssertionError: expected 1 to be +0
FAIL (a2) AssertionError: expected 1 to be +0
GREEN — after the fix (commit 2b8d4e2e):
Test Files 1 passed (1)
Tests 6 passed (6)
The 4 required categories pass: Claude = 0 sentinels (message_stop forwarded), OpenAI no-upstream-[DONE] = 1, OpenAI with upstream [DONE] = 1 (exactly-once), gemini-family = 0. The gemini provider gate and the streamDoneSent latch are untouched, as is every TRANSLATE-mode path.
# Evidence - Problem class: sse-passthrough-terminator-per-client-format-gate - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-15T11:11:55.302Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "9router open-sse/utils/stream.js PASSTHROUGH flush emitted a synthetic 'data: [DONE]' for every client (OpenClaw anti-hang workaround) gated only on provider gemini-family \u2014 so Claude clients on a claude-native passthrough upstream received the upstream message_stop PLUS an extra [DONE], malformed per Anthropic contract. Fix: gate on sourceFormat != CLAUDE; plumb sourceFormat through createPassthroughStreamWithLogger (streamingHandler.js:44). Keep OpenAI exactly-once workaround, keep gemini gate. Regression suite must cover 4 cases incl. RED-proof. Verified fix: 9router commit 2b8d4e2e, judge PASS bf7d51ac.", "environment": "9router fork, open-sse streaming transform", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sse-passthrough-terminator-per-client-format-gate", "provider": "openrouter", "solved_at": "2026-09-15T11:11:55.303Z", "version": ""}I found the exact upstream commit (2b8d4e2e, decolua/9router) and its parent (a99ebb73), applied the fix in isolated worktrees, and captured RED→GREEN evidence. Here is the self-contained solution.
data: [DONE] sentinel must be gated on client format, not just providerProblem class: sse-passthrough-terminator-per-client-format-gate
Repo: 9router — open-sse streaming transform
Verified commit: 2b8d4e2e (parent a99ebb73), judge bf7d51ac
In open-sse/utils/stream.js, the PASSTHROUGH branch of the transform's flush() always appended a synthetic OpenAI terminator:
const isGeminiFamily = provider === "antigravity" || provider === "gemini" || provider === "vertex";
if (!streamDoneSent && !isGeminiFamily) {
const doneOutput = "data: [DONE]\n\n";
...
}
That sentinel is an OpenAI-client terminator (the OpenClaw anti-hang workaround: a client that never sees it can hang until timeout and trigger failover). It was suppressed only for gemini-family providers, so it was never gated on the client format.
In passthrough mode the client format is the upstream format (client format === upstream format). So an Anthropic client (/v1/messages, sourceFormat === FORMATS.CLAUDE) talking to a Claude-native upstream received:
event: message_stop (data: {"type":"message_stop"}), forwarded verbatim, plusdata: [DONE]\n\n appended by the flush.The Anthropic contract terminates on message_stop; a trailing OpenAI sentinel makes the stream end malformed. The bug only manifests in passthrough (no translation) — the TRANSLATE path already latches streamDoneSent when it sees/emits a sentinel.
Note: the streamDoneSent exactly-once latch was already present and correct in the parent snapshot (it is set for upstream [DONE] at stream.js:144 and for other paths), so only the gate needs fixing.
Three edits. No behavior change to TRANSLATE mode.
open-sse/handlers/chatCore/streamingHandler.js — plumb sourceFormat@@ function buildTransformStream(...) @@
- return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
+ // Passthrough (client format === upstream format) still needs the client
+ // format: stream.js uses it to decide whether the synthetic `data: [DONE]`
+ // terminator belongs to the client's contract (OpenAI clients keep the
+ // OpenClaw-hang workaround; Claude clients end on message_stop).
+ return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, sourceFormat);
open-sse/utils/stream.js — accept sourceFormat in the passthrough factory-export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
+export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, sourceFormat = null) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
provider,
reqLogger,
model,
connectionId,
body,
onStreamComplete,
- apiKey
+ apiKey,
+ // Client format: passthrough mode uses it only to decide whether the
+ // synthetic `data: [DONE]` terminator belongs to the client's contract
+ // (OpenAI) or not (Claude — message_stop, gemini-family — 400s).
+ sourceFormat
});
}
open-sse/utils/stream.js — gate the flush sentinel on client format@@ PASSTHROUGH branch of flush() @@
- // Gemini-family clients (Antigravity, Vertex, Gemini) reject this sentinel with 400 syntax errors.
+ // The sentinel is an OpenAI-client terminator, not a universal one:
+ // Gemini-family clients (Antigravity, Vertex, Gemini) reject it with 400
+ // syntax errors, and a Claude client's contract ends on the upstream's own
+ // `message_stop` (forwarded verbatim above) — appending [DONE] after it
+ // hands an Anthropic client a malformed stream end. Passthrough means the
+ // client format IS the upstream format (sourceFormat), so gate on it.
const isGeminiFamily = provider === "antigravity" || provider === "gemini" || provider === "vertex";
- if (!streamDoneSent && !isGeminiFamily) {
+ const isClaudeClient = sourceFormat === FORMATS.CLAUDE;
+ if (!streamDoneSent && !isGeminiFamily && !isClaudeClient) {
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(sharedEncoder.encode(doneOutput));
}
FORMATS is already imported in stream.js; FORMATS.CLAUDE === "claude".
What is preserved: the OpenAI hang workaround (exactly one sentinel), the gemini-family provider gate, the existing streamDoneSent exactly-once latch, and every TRANSLATE-mode path.
tests/unit/passthrough-done-sentinel-gate.test.js drives the real wire path (handleChatCore → streamingHandler → buildTransformStream → stream.js) via a stubbed executor, plus the passthrough flush directly. It covers the 4 required cases:
| Case | Client | Upstream | Expected sentinels |
|---|---|---|---|
| (a) / (a2) | Claude (/v1/messages) |
claude-native, ends message_stop |
0 (message_stop forwarded) |
| (b) | OpenAI | chat-native, no [DONE] |
1 (hang workaround) |
| (c) | OpenAI | chat-native, sends its own [DONE] |
1 (exactly-once latch) |
| (d) / (d2) | gemini-family | gemini-format | 0 (provider gate intact) |
// tests/unit/passthrough-done-sentinel-gate.test.js
// DF-9ROUTER-22 — the synthetic `data: [DONE]` in PASSTHROUGH mode is an
// OpenAI-client terminator, not a universal one.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, afterAll, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {}),
trackPendingRequest: vi.fn(async () => {}),
}));
const executorStub = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("../../open-sse/executors/index.js", () => ({
getExecutor: () => ({
provider: "probe",
noAuth: true,
execute: executorStub.execute,
refreshCredentials: async () => null,
}),
}));
const originalDataDir = process.env.DATA_DIR;
let tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-passthrough-done-"));
process.env.DATA_DIR = tempDir;
vi.resetModules();
const { FORMATS } = await import("../../open-sse/translator/formats.js");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js");
const { createPassthroughStreamWithLogger } = await import("../../open-sse/utils/stream.js");
const SENTINEL = "data: [DONE]";
const MODEL = "muse-spark-1.2-contributor-free";
const sentinelCount = (body) => body.split(SENTINEL).length - 1;
afterAll(() => {
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});
const CLAUDE_SSE = [
"event: message_start",
'data: {"type":"message_start","message":{"id":"msg_probe","type":"message","role":"assistant","model":"claude-probe","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":15,"output_tokens":1}}}',
"",
"event: content_block_start",
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
"",
"event: content_block_delta",
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"DOGFOOD_"}}',
"",
"event: content_block_delta",
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}',
"",
"event: content_block_stop",
'data: {"type":"content_block_stop","index":0}',
"",
"event: message_delta",
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}',
"",
"event: message_stop",
'data: {"type":"message_stop"}',
"",
].join("\n");
const OPENAI_NO_DONE_SSE = [
'data: {"id":"chatcmpl-probe","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"DOGFOOD_"},"finish_reason":null}]}',
"",
'data: {"id":"chatcmpl-probe","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"OK"},"finish_reason":null}]}',
"",
'data: {"id":"chatcmpl-probe","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":2,"total_tokens":17}}',
"",
].join("\n");
const OPENAI_WITH_DONE_SSE = `${OPENAI_NO_DONE_SSE}${SENTINEL}\n\n`;
const GEMINI_SSE = [
'data: {"candidates":[{"index":0,"content":{"parts":[{"text":"DOGFOOD_"}],"role":"model"}}],"modelVersion":"gemini-probe"}',
"",
'data: {"candidates":[{"index":0,"content":{"parts":[{"text":"OK"}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-probe"}',
"",
].join("\n");
function sseResponse(text) {
const bytes = new TextEncoder().encode(text);
return new Response(new ReadableStream({
start(controller) { controller.enqueue(bytes); controller.close(); },
}), { status: 200, headers: { "content-type": "text/event-stream" } });
}
async function streamViaWire({ provider, model, body, upstream, responseFormat, endpoint = "/v1/chat/completions", headers = { "content-type": "application/json" }, sourceFormatOverride = null }) {
executorStub.execute.mockImplementation(async ({ body: sent }) => ({
response: upstream,
url: `https://probe.invalid/${provider}/v1/x`,
headers: { "content-type": upstream.headers.get("content-type") },
transformedBody: sent,
responseFormat,
}));
const result = await handleChatCore({
body,
modelInfo: { provider, model },
credentials: {},
clientRawRequest: { endpoint, headers, body },
stream: true,
...(sourceFormatOverride ? { sourceFormatOverride } : {}),
});
return result.response.text();
}
async function runPassthroughStream(provider, sourceFormat, input) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) { controller.enqueue(encoder.encode(input)); controller.close(); },
});
const reader = stream
.pipeThrough(createPassthroughStreamWithLogger(provider, null, MODEL, null, null, null, null, sourceFormat))
.getReader();
const decoder = new TextDecoder();
let text = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
const dataLines = (body) => body.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("data:"));
const jsonEvents = (body) => dataLines(body)
.map((l) => l.slice(5).trim())
.filter((p) => p && p !== "[DONE]")
.map((p) => { try { return JSON.parse(p); } catch { return null; } })
.filter(Boolean);
const claudeText = (body) => jsonEvents(body)
.filter((e) => e.type === "content_block_delta" && e.delta?.type === "text_delta")
.map((e) => e.delta.text).join("");
const openaiText = (body) => jsonEvents(body).map((e) => e.choices?.[0]?.delta?.content || "").join("");
const geminiText = (body) => jsonEvents(body)
.flatMap((e) => e.candidates?.[0]?.content?.parts || []).map((p) => p.text || "").join("");
const anthropicBody = () => ({
model: `anthropic/claude-sonnet-4-20250514`, max_tokens: 64,
messages: [{ role: "user", content: "Reply with exactly: DOGFOOD_OK" }], stream: true,
});
const openaiBody = () => ({
model: `openai/gpt-5.4`,
messages: [{ role: "user", content: "Reply with exactly: DOGFOOD_OK" }], stream: true,
});
const geminiBody = () => ({
model: `gemini/gemini-3.8-flash`,
contents: [{ role: "user", parts: [{ text: "Reply with exactly: DOGFOOD_OK" }] }], stream: true,
});
describe("DF-9ROUTER-22 — passthrough [DONE] sentinel is OpenAI-client-only", () => {
it("(a) emits NO synthetic sentinel for a Claude-format client and forwards the upstream message_stop", async () => {
const body = await streamViaWire({
provider: "anthropic", model: "claude-sonnet-4-20250514", body: anthropicBody(),
upstream: sseResponse(CLAUDE_SSE), responseFormat: FORMATS.CLAUDE,
endpoint: "/v1/messages",
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
sourceFormatOverride: FORMATS.CLAUDE,
});
expect(body).toContain("message_stop");
expect(jsonEvents(body).some((e) => e.type === "message_stop")).toBe(true);
expect(claudeText(body)).toBe("DOGFOOD_OK");
expect(sentinelCount(body)).toBe(0);
expect(body).not.toContain(SENTINEL);
});
it("(a2) the passthrough flush itself suppresses the sentinel for a Claude sourceFormat", async () => {
const body = await runPassthroughStream("anthropic", FORMATS.CLAUDE, CLAUDE_SSE);
expect(sentinelCount(body)).toBe(0);
expect(body).toContain("message_stop");
expect(claudeText(body)).toBe("DOGFOOD_OK");
});
it("(b) still emits exactly one sentinel for an OpenAI client when the upstream sends none", async () => {
const body = await streamViaWire({
provider: "openai", model: "gpt-5.4", body: openaiBody(),
upstream: sseResponse(OPENAI_NO_DONE_SSE), responseFormat: FORMATS.OPENAI,
});
expect(sentinelCount(body)).toBe(1);
expect(body.trimEnd().endsWith(SENTINEL)).toBe(true);
expect(openaiText(body)).toBe("DOGFOOD_OK");
});
it("(c) does not duplicate the sentinel when an OpenAI passthrough upstream sends its own", async () => {
const body = await streamViaWire({
provider: "openai", model: "gpt-5.4", body: openaiBody(),
upstream: sseResponse(OPENAI_WITH_DONE_SSE), responseFormat: FORMATS.OPENAI,
});
expect(sentinelCount(body)).toBe(1);
expect(openaiText(body)).toBe("DOGFOOD_OK");
});
it("(d) emits no sentinel for a gemini-family passthrough provider", async () => {
const body = await streamViaWire({
provider: "gemini", model: "gemini-3.8-flash", body: geminiBody(),
upstream: sseResponse(GEMINI_SSE), responseFormat: FORMATS.GEMINI,
sourceFormatOverride: FORMATS.GEMINI,
});
expect(sentinelCount(body)).toBe(0);
expect(geminiText(body)).toBe("DOGFOOD_OK");
});
it("(d2) the passthrough flush itself keeps the gemini-family gate", async () => {
const body = await runPassthroughStream("gemini", FORMATS.GEMINI, GEMINI_SSE);
expect(sentinelCount(body)).toBe(0);
expect(geminiText(body)).toBe("DOGFOOD_OK");
});
});
Install test deps (once) and run from tests/:
cd tests && npm install --legacy-peer-deps
npx vitest run --config ./vitest.config.js unit/passthrough-done-sentinel-gate.test.js
RED — before the fix (parent a99ebb73, test copied in). The Claude cases fail, proving the bug is real:
Test Files 1 failed (1)
Tests 2 failed | 4 passed (6)
FAIL (a) AssertionError: expected 1 to be +0
FAIL (a2) AssertionError: expected 1 to be +0
GREEN — after the fix (commit 2b8d4e2e):
Test Files 1 passed (1)
Tests 6 passed (6)
The 4 required categories pass: Claude = 0 sentinels (message_stop forwarded), OpenAI no-upstream-[DONE] = 1, OpenAI with upstream [DONE] = 1 (exactly-once), gemini-family = 0. The gemini provider gate and the streamDoneSent latch are untouched, as is every TRANSLATE-mode path.
# Evidence - Problem class: sse-passthrough-terminator-per-client-format-gate - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-15T11:11:55.302Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "9router open-sse/utils/stream.js PASSTHROUGH flush emitted a synthetic 'data: [DONE]' for every client (OpenClaw anti-hang workaround) gated only on provider gemini-family \u2014 so Claude clients on a claude-native passthrough upstream received the upstream message_stop PLUS an extra [DONE], malformed per Anthropic contract. Fix: gate on sourceFormat != CLAUDE; plumb sourceFormat through createPassthroughStreamWithLogger (streamingHandler.js:44). Keep OpenAI exactly-once workaround, keep gemini gate. Regression suite must cover 4 cases incl. RED-proof. Verified fix: 9router commit 2b8d4e2e, judge PASS bf7d51ac.", "environment": "9router fork, open-sse streaming transform", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sse-passthrough-terminator-per-client-format-gate", "provider": "openrouter", "solved_at": "2026-09-15T11:11:55.303Z", "version": ""}