Repo: totalwindupflightsystems/9router · Branch: federation · Fix commit: bb80ea17 · Task: DF-9ROUTER-23
I cloned the federation branch, reproduced the bug against the parent commit, applied/confirmed the bb80ea17 fix, and verified both the RED baseline and the green HEAD. The full solution is saved at ~/DF-9ROUTER-23-solution.md and reproduced below.
Repo: totalwindupflightsystems/9router · Branch: federation · Fix commit: bb80ea17 · Task: DF-9ROUTER-23
Symptom: A Responses-API upstream (opencode.ai /zen/v1/responses) closes its SSE stream with no terminal event, while status is still in_progress and there is zero output. 9router then answers:
POST /v1/chat/completions → HTTP 200, choices[0].message.content = "", finish_reason = "in_progress" (not an OpenAI enum value), usage = 0/0/0.POST /v1/messages → content:[{type:"text",text:""}], stop_reason:"end_turn".Both are indistinguishable from a legitimate empty answer, so a health check cannot tell "the model produced nothing" from "the model had nothing to say".
Two files cooperated to hide the failure:
open-sse/transformer/streamToJsonConverter.js only changed state.status when a terminal event arrived (response.completed / response.done / response.failed). A stream that simply closed left status: "in_progress" and — crucially — there was no signal at all that the upstream had never said how it ended. The converter returned a body that looked like a normal Responses object with an empty output.
open-sse/handlers/chatCore/sseToJsonHandler.js took that raw upstream status and copied it straight into the client-visible finish_reason:
js
// pre-fix (~line 268)
const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done";
const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop"));
and into the recorded request-detail row:
js
// pre-fix (~line 223)
response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" },
So in_progress on the wire and in the DB. When the client was Claude, the chat body was converted to content:[{type:"text",text:""}] + stop_reason:"end_turn" — a completely plausible empty answer.
The core mistake: stream termination was inferred from status instead of being tracked explicitly. A Responses body may legitimately carry in_progress, so status cannot double as the "did it finish?" flag.
Commit bb80ea17 on federation. It makes the converter record a dedicated terminal flag, makes the handler fail loud before assembling any client body, and clamps finish_reason to the OpenAI enum on every 200 path.
open-sse/transformer/streamToJsonConverter.jsAdd a terminal flag to the accumulated state and set it only on a real terminal event. Also record response.incomplete and capture usage uniformly.
/**
* Fold a terminal event's usage block into the accumulated state.
*/
function captureUsage(response, state) {
if (!response?.usage) return;
state.usage.input_tokens = response.usage.input_tokens || 0;
state.usage.output_tokens = response.usage.output_tokens || 0;
state.usage.total_tokens = response.usage.total_tokens || 0;
}
// ... inside processSSEMessage:
} else if (eventType === "response.completed" || eventType === "response.done") {
state.status = "completed";
state.terminal = true;
captureUsage(parsed.response, state);
} else if (eventType === "response.incomplete") {
// The upstream stopped early (max_output_tokens / content filter). The answer
// is truncated, but it IS how the upstream said it ended.
state.status = "incomplete";
state.terminal = true;
captureUsage(parsed.response, state);
} else if (eventType === "response.failed") {
state.status = "failed";
state.terminal = true;
}
The stream state and both returns carry the flag:
const state = {
responseId: "",
created: Math.floor(Date.now() / 1000),
status: "in_progress",
// Whether the upstream ever said how it ended (`response.completed` /
// `response.done` / `response.incomplete` / `response.failed`). A stream that
// just closes leaves `status` at whatever it last was — `in_progress` — which
// is NOT an answer.
terminal: false,
usage: { ...EMPTY_RESPONSE },
items: new Map(),
textByIndex: new Map()
};
return {
id: state.responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
object: "response",
created_at: state.created,
status: state.status || "completed",
// `status` is left at its upstream value (a Responses body may legitimately
// be `in_progress`); this flag is what a consumer must check before treating
// an empty `output` as an answer.
terminal: state.terminal,
output,
usage: state.usage
};
And the unreadable-stream fallback reports terminal: false:
if (!stream || typeof stream.getReader !== "function") {
return { id: `resp_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "failed", terminal: false, output: [], usage: { ...EMPTY_RESPONSE } };
}
open-sse/handlers/chatCore/sseToJsonHandler.jsImport the enum and add the status→finish_reason mapper:
import { ROLE, RESPONSES_ITEM, OPENAI_FINISH } from "../../translator/schema/index.js";
/**
* Upstream Responses API status → OpenAI `finish_reason`.
*
* A Responses body legitimately carries `in_progress` / `queued` / `failed` /
* `incomplete`, and none of those are OpenAI finish_reason values. `incomplete`
* means the upstream cut the answer short at max_output_tokens, so it maps to
* `length`; every other status — including a non-terminal one — is the explicit
* `stop` default. The result is always inside the OpenAI enum.
*/
function finishReasonFromResponsesStatus(status, hasToolCalls) {
if (hasToolCalls) return OPENAI_FINISH.TOOL_CALLS;
return status === "incomplete" ? OPENAI_FINISH.LENGTH : OPENAI_FINISH.STOP;
}
Inside the Responses branch, fail loud before any client body is assembled and compute the single mapped finishReason used by both the client and the DB:
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
// A Responses upstream that closed the stream WITHOUT a terminal event has
// not answered, so a body with no assistant output there is a failed upstream
// (quota, abort, free-route rate limit) — not an empty answer. Fail loud
// instead. A COMPLETED upstream with empty output is still a 200.
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
// A tool-only answer is not an empty one: `function_call` items (and their
// Responses `custom_tool_call` sibling) are real assistant output.
const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call");
const toolItems = (jsonResponse.output || []).filter(item => item.type === "function_call" || item.type === "custom_tool_call");
const hasAssistantOutput = (typeof textContent === "string" && textContent.length > 0) || toolItems.length > 0;
if (!jsonResponse.terminal && !hasAssistantOutput) {
console.error("[ChatCore] Responses API upstream closed without a terminal event and produced no output");
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Upstream returned no output and never completed (upstream_empty_completion)");
}
// Tool calls are Responses `function_call` items and pin finish_reason to
// `tool_calls`. The ONE mapped finish_reason below is used by both the client
// body and the recorded detail row.
const toolCalls = funcCallItems.map((item, idx) => ({
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
type: "function",
function: {
name: item.name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
}
}));
const hasToolCalls = toolCalls.length > 0;
const finishReason = finishReasonFromResponsesStatus(jsonResponse.status, hasToolCalls);
Record the mapped value in the detail row:
response: { content: textContent, thinking: null, finish_reason: finishReason },
Delete the old raw-status copy (the responseDone/jsonResponse.status expression) from the chat-completion assembly; finishReason is already in scope:
const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") };
if (hasToolCalls) message.tool_calls = toolCalls;
const chatCompletion = {
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: jsonResponse.created_at || Math.floor(Date.now() / 1000),
model: jsonResponse.model || model,
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens, ...cacheDetails }
};
Because the fail-loud check runs before onRequestSuccess(), appendLog, saveUsageStats, and saveRequestDetail, a broken upstream is not booked as a successful request either.
git clone https://github.com/totalwindupflightsystems/9router.git
cd 9router
git checkout federation
git cherry-pick bb80ea17 # or: git checkout bb80ea17 -- \
# open-sse/transformer/streamToJsonConverter.js \
# open-sse/handlers/chatCore/sseToJsonHandler.js \
# tests/unit/nonstream-responses-nonterminal.test.js
The fix ships with tests/unit/nonstream-responses-nonterminal.test.js (14 tests). It is RED pre-fix and green at HEAD.
# from the repo root
npm ci # root deps (open-sse imports undici, etc.)
cd tests && npm ci # test runner deps (vitest)
# HEAD / post-fix
npx vitest run unit/nonstream-responses-nonterminal.test.js
Observed at HEAD (bb80ea17 / the federation tip):
Test Files 1 passed (1)
Tests 14 passed (14)
Observed with the two source files reverted to the parent commit (20f0601) while keeping the new test file — i.e. the pre-fix code:
git checkout 20f0601 -- \
open-sse/transformer/streamToJsonConverter.js \
open-sse/handlers/chatCore/sseToJsonHandler.js
cd tests && npx vitest run unit/nonstream-responses-nonterminal.test.js
# restore
git checkout HEAD -- \
open-sse/transformer/streamToJsonConverter.js \
open-sse/handlers/chatCore/sseToJsonHandler.js
Result:
Test Files 1 failed (1)
Tests 11 failed | 3 passed (14)
Representative pre-fix failures:
AssertionError: expected 'in_progress' to be 'length'
a truncated (incomplete) upstream answer → 200 with finish_reason "length"
AssertionError: expected 'in_progress' to be 'stop'
records the MAPPED finish_reason in the request detail (never the raw upstream status)
The 14 tests pin the contract:
response.completed / response.done / response.incomplete / response.failed was seen, while leaving status at the upstream value;response.incomplete / response.failed / response.completed / response.done are each terminal;upstream_empty_completion for both /v1/chat/completions and /v1/messages, with no choices / content / stop_reason leaked and no success bookkeeping;finish_reason;finish_reason:"stop";incomplete → length; tool calls → tool_calls; everything else → stop;finish_reason the client received, never the raw upstream status.| Upstream | Output | Result |
|---|---|---|
| missing terminal event | none | 502 upstream_empty_completion (chat + Claude shapes) |
| missing terminal event | assistant text | 200, finish_reason: stop |
response.completed / response.done |
none | 200, finish_reason: stop (legit empty answer) |
response.incomplete |
truncated text | 200, finish_reason: length |
| any terminal | tool call items | 200, finish_reason: tool_calls |
bb80ea17CHANGELOG.md
open-sse/handlers/chatCore/sseToJsonHandler.js
open-sse/transformer/streamToJsonConverter.js
tests/unit/nonstream-responses-nonterminal.test.js
# Evidence - Problem class: sse-nonterminal-upstream-empty-completion-relayed-as-success - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-15T23:41:24.418Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Responses-API upstream (opencode.ai /zen/v1/responses) closes its SSE stream with NO response.completed while status is still in_progress and zero output. The non-streaming chat-completions path then returns HTTP 200 with finish_reason=in_progress (not an OpenAI enum value) and empty content, and the Claude /v1/messages shape returns content=[{type:text,text:}] with stop_reason=end_turn - indistinguishable from a legitimate empty answer, so a health check cannot tell 'model produced nothing' from 'model had nothing to say'. ROOT CAUSE (9router): open-sse/transformer/streamToJsonConverter.js only moves state.status on a terminal event, so an aborted stream leaves in_progress; open-sse/handlers/chatCore/sseToJsonHandler.js then copied that raw upstream status straight into the client-visible finish_reason (pre-fix line ~268) and into the recorded request detail (line ~223). FIX: the converter records a terminal flag (true only for response.completed/done, response.incomplete, response.failed); the handler returns 502 upstream_empty_completion when !terminal AND no assistant text and no tool items, before assembling any client body (so both the chat-completions and the Claude shape fail loud); every 200 path clamps finish_reason to the OpenAI enum (incomplete->length, tool-calls->tool_calls, else stop) and the detail row records the same mapped value. VERIFIED: new suite tests/unit/nonstream-responses-nonterminal.test.js (14 tests) is RED pre-fix (11 failed / 3 passed) and 14/14 at HEAD; commit bb80ea17 on 9router federation.", "environment": "linux", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sse-nonterminal-upstream-empty-completion-relayed-as-success", "provider": "openrouter", "solved_at": "2026-09-15T23:41:24.419Z", "version": "node-22 nextjs-16 9router open-sse"}I cloned the federation branch, reproduced the bug against the parent commit, applied/confirmed the bb80ea17 fix, and verified both the RED baseline and the green HEAD. The full solution is saved at ~/DF-9ROUTER-23-solution.md and reproduced below.
Repo: totalwindupflightsystems/9router · Branch: federation · Fix commit: bb80ea17 · Task: DF-9ROUTER-23
Symptom: A Responses-API upstream (opencode.ai /zen/v1/responses) closes its SSE stream with no terminal event, while status is still in_progress and there is zero output. 9router then answers:
POST /v1/chat/completions → HTTP 200, choices[0].message.content = "", finish_reason = "in_progress" (not an OpenAI enum value), usage = 0/0/0.POST /v1/messages → content:[{type:"text",text:""}], stop_reason:"end_turn".Both are indistinguishable from a legitimate empty answer, so a health check cannot tell "the model produced nothing" from "the model had nothing to say".
Two files cooperated to hide the failure:
open-sse/transformer/streamToJsonConverter.js only changed state.status when a terminal event arrived (response.completed / response.done / response.failed). A stream that simply closed left status: "in_progress" and — crucially — there was no signal at all that the upstream had never said how it ended. The converter returned a body that looked like a normal Responses object with an empty output.
open-sse/handlers/chatCore/sseToJsonHandler.js took that raw upstream status and copied it straight into the client-visible finish_reason:
js
// pre-fix (~line 268)
const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done";
const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop"));
and into the recorded request-detail row:
js
// pre-fix (~line 223)
response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" },
So in_progress on the wire and in the DB. When the client was Claude, the chat body was converted to content:[{type:"text",text:""}] + stop_reason:"end_turn" — a completely plausible empty answer.
The core mistake: stream termination was inferred from status instead of being tracked explicitly. A Responses body may legitimately carry in_progress, so status cannot double as the "did it finish?" flag.
Commit bb80ea17 on federation. It makes the converter record a dedicated terminal flag, makes the handler fail loud before assembling any client body, and clamps finish_reason to the OpenAI enum on every 200 path.
open-sse/transformer/streamToJsonConverter.jsAdd a terminal flag to the accumulated state and set it only on a real terminal event. Also record response.incomplete and capture usage uniformly.
/**
* Fold a terminal event's usage block into the accumulated state.
*/
function captureUsage(response, state) {
if (!response?.usage) return;
state.usage.input_tokens = response.usage.input_tokens || 0;
state.usage.output_tokens = response.usage.output_tokens || 0;
state.usage.total_tokens = response.usage.total_tokens || 0;
}
// ... inside processSSEMessage:
} else if (eventType === "response.completed" || eventType === "response.done") {
state.status = "completed";
state.terminal = true;
captureUsage(parsed.response, state);
} else if (eventType === "response.incomplete") {
// The upstream stopped early (max_output_tokens / content filter). The answer
// is truncated, but it IS how the upstream said it ended.
state.status = "incomplete";
state.terminal = true;
captureUsage(parsed.response, state);
} else if (eventType === "response.failed") {
state.status = "failed";
state.terminal = true;
}
The stream state and both returns carry the flag:
const state = {
responseId: "",
created: Math.floor(Date.now() / 1000),
status: "in_progress",
// Whether the upstream ever said how it ended (`response.completed` /
// `response.done` / `response.incomplete` / `response.failed`). A stream that
// just closes leaves `status` at whatever it last was — `in_progress` — which
// is NOT an answer.
terminal: false,
usage: { ...EMPTY_RESPONSE },
items: new Map(),
textByIndex: new Map()
};
return {
id: state.responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
object: "response",
created_at: state.created,
status: state.status || "completed",
// `status` is left at its upstream value (a Responses body may legitimately
// be `in_progress`); this flag is what a consumer must check before treating
// an empty `output` as an answer.
terminal: state.terminal,
output,
usage: state.usage
};
And the unreadable-stream fallback reports terminal: false:
if (!stream || typeof stream.getReader !== "function") {
return { id: `resp_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "failed", terminal: false, output: [], usage: { ...EMPTY_RESPONSE } };
}
open-sse/handlers/chatCore/sseToJsonHandler.jsImport the enum and add the status→finish_reason mapper:
import { ROLE, RESPONSES_ITEM, OPENAI_FINISH } from "../../translator/schema/index.js";
/**
* Upstream Responses API status → OpenAI `finish_reason`.
*
* A Responses body legitimately carries `in_progress` / `queued` / `failed` /
* `incomplete`, and none of those are OpenAI finish_reason values. `incomplete`
* means the upstream cut the answer short at max_output_tokens, so it maps to
* `length`; every other status — including a non-terminal one — is the explicit
* `stop` default. The result is always inside the OpenAI enum.
*/
function finishReasonFromResponsesStatus(status, hasToolCalls) {
if (hasToolCalls) return OPENAI_FINISH.TOOL_CALLS;
return status === "incomplete" ? OPENAI_FINISH.LENGTH : OPENAI_FINISH.STOP;
}
Inside the Responses branch, fail loud before any client body is assembled and compute the single mapped finishReason used by both the client and the DB:
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
// A Responses upstream that closed the stream WITHOUT a terminal event has
// not answered, so a body with no assistant output there is a failed upstream
// (quota, abort, free-route rate limit) — not an empty answer. Fail loud
// instead. A COMPLETED upstream with empty output is still a 200.
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
// A tool-only answer is not an empty one: `function_call` items (and their
// Responses `custom_tool_call` sibling) are real assistant output.
const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call");
const toolItems = (jsonResponse.output || []).filter(item => item.type === "function_call" || item.type === "custom_tool_call");
const hasAssistantOutput = (typeof textContent === "string" && textContent.length > 0) || toolItems.length > 0;
if (!jsonResponse.terminal && !hasAssistantOutput) {
console.error("[ChatCore] Responses API upstream closed without a terminal event and produced no output");
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Upstream returned no output and never completed (upstream_empty_completion)");
}
// Tool calls are Responses `function_call` items and pin finish_reason to
// `tool_calls`. The ONE mapped finish_reason below is used by both the client
// body and the recorded detail row.
const toolCalls = funcCallItems.map((item, idx) => ({
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
type: "function",
function: {
name: item.name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
}
}));
const hasToolCalls = toolCalls.length > 0;
const finishReason = finishReasonFromResponsesStatus(jsonResponse.status, hasToolCalls);
Record the mapped value in the detail row:
response: { content: textContent, thinking: null, finish_reason: finishReason },
Delete the old raw-status copy (the responseDone/jsonResponse.status expression) from the chat-completion assembly; finishReason is already in scope:
const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") };
if (hasToolCalls) message.tool_calls = toolCalls;
const chatCompletion = {
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: jsonResponse.created_at || Math.floor(Date.now() / 1000),
model: jsonResponse.model || model,
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens, ...cacheDetails }
};
Because the fail-loud check runs before onRequestSuccess(), appendLog, saveUsageStats, and saveRequestDetail, a broken upstream is not booked as a successful request either.
git clone https://github.com/totalwindupflightsystems/9router.git
cd 9router
git checkout federation
git cherry-pick bb80ea17 # or: git checkout bb80ea17 -- \
# open-sse/transformer/streamToJsonConverter.js \
# open-sse/handlers/chatCore/sseToJsonHandler.js \
# tests/unit/nonstream-responses-nonterminal.test.js
The fix ships with tests/unit/nonstream-responses-nonterminal.test.js (14 tests). It is RED pre-fix and green at HEAD.
# from the repo root
npm ci # root deps (open-sse imports undici, etc.)
cd tests && npm ci # test runner deps (vitest)
# HEAD / post-fix
npx vitest run unit/nonstream-responses-nonterminal.test.js
Observed at HEAD (bb80ea17 / the federation tip):
Test Files 1 passed (1)
Tests 14 passed (14)
Observed with the two source files reverted to the parent commit (20f0601) while keeping the new test file — i.e. the pre-fix code:
git checkout 20f0601 -- \
open-sse/transformer/streamToJsonConverter.js \
open-sse/handlers/chatCore/sseToJsonHandler.js
cd tests && npx vitest run unit/nonstream-responses-nonterminal.test.js
# restore
git checkout HEAD -- \
open-sse/transformer/streamToJsonConverter.js \
open-sse/handlers/chatCore/sseToJsonHandler.js
Result:
Test Files 1 failed (1)
Tests 11 failed | 3 passed (14)
Representative pre-fix failures:
AssertionError: expected 'in_progress' to be 'length'
a truncated (incomplete) upstream answer → 200 with finish_reason "length"
AssertionError: expected 'in_progress' to be 'stop'
records the MAPPED finish_reason in the request detail (never the raw upstream status)
The 14 tests pin the contract:
response.completed / response.done / response.incomplete / response.failed was seen, while leaving status at the upstream value;response.incomplete / response.failed / response.completed / response.done are each terminal;upstream_empty_completion for both /v1/chat/completions and /v1/messages, with no choices / content / stop_reason leaked and no success bookkeeping;finish_reason;finish_reason:"stop";incomplete → length; tool calls → tool_calls; everything else → stop;finish_reason the client received, never the raw upstream status.| Upstream | Output | Result |
|---|---|---|
| missing terminal event | none | 502 upstream_empty_completion (chat + Claude shapes) |
| missing terminal event | assistant text | 200, finish_reason: stop |
response.completed / response.done |
none | 200, finish_reason: stop (legit empty answer) |
response.incomplete |
truncated text | 200, finish_reason: length |
| any terminal | tool call items | 200, finish_reason: tool_calls |
bb80ea17CHANGELOG.md
open-sse/handlers/chatCore/sseToJsonHandler.js
open-sse/transformer/streamToJsonConverter.js
tests/unit/nonstream-responses-nonterminal.test.js
# Evidence - Problem class: sse-nonterminal-upstream-empty-completion-relayed-as-success - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-15T23:41:24.418Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Responses-API upstream (opencode.ai /zen/v1/responses) closes its SSE stream with NO response.completed while status is still in_progress and zero output. The non-streaming chat-completions path then returns HTTP 200 with finish_reason=in_progress (not an OpenAI enum value) and empty content, and the Claude /v1/messages shape returns content=[{type:text,text:}] with stop_reason=end_turn - indistinguishable from a legitimate empty answer, so a health check cannot tell 'model produced nothing' from 'model had nothing to say'. ROOT CAUSE (9router): open-sse/transformer/streamToJsonConverter.js only moves state.status on a terminal event, so an aborted stream leaves in_progress; open-sse/handlers/chatCore/sseToJsonHandler.js then copied that raw upstream status straight into the client-visible finish_reason (pre-fix line ~268) and into the recorded request detail (line ~223). FIX: the converter records a terminal flag (true only for response.completed/done, response.incomplete, response.failed); the handler returns 502 upstream_empty_completion when !terminal AND no assistant text and no tool items, before assembling any client body (so both the chat-completions and the Claude shape fail loud); every 200 path clamps finish_reason to the OpenAI enum (incomplete->length, tool-calls->tool_calls, else stop) and the detail row records the same mapped value. VERIFIED: new suite tests/unit/nonstream-responses-nonterminal.test.js (14 tests) is RED pre-fix (11 failed / 3 passed) and 14/14 at HEAD; commit bb80ea17 on 9router federation.", "environment": "linux", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sse-nonterminal-upstream-empty-completion-relayed-as-success", "provider": "openrouter", "solved_at": "2026-09-15T23:41:24.419Z", "version": "node-22 nextjs-16 9router open-sse"}