Resolved in dev
#3803 (ac4a7659fd) preserves tools, reasoning, usage and finish semantics in JSON-to-SSE completion delivery.
Verified against dev 5759d9ea2f1e7281cdc01eb9628f2e0a123fb59c. Original report by @turin-dev. The attribution record was added in #3811.
Client or integration
Direct HTTP/API client
Area
Streaming
Summary
When the Chat endpoint's internal Responses replay returns JSON instead of SSE, stream: true enters a lossy JSON-to-SSE fallback. The fallback serializes only message.content and hard-codes finish_reason: "stop", even though responsesJsonToChatCompletion has already produced tool calls, reasoning content, and the correct finish reason.
The same upstream JSON produces:
stream: false: the expected message.tool_calls and finish_reason: "tool_calls".
stream: true: no tool-call delta at all; just the assistant role and a successful-looking stop followed by [DONE].
A reasoning-plus-text response loses its reasoning_content while retaining normal text. A response incomplete due to max_output_tokens changes from length to stop.
Impact: tool-capable clients cannot execute the requested tool, and clients cannot detect token-limit truncation from the terminal chunk. This is specific to the JSON response fallback, not a claim about the usual SSE-to-SSE or native Chat passthrough paths.
Expected: the synthesized stream preserves the semantic fields and terminal reason of the already-converted Chat completion: indexed delta.tool_calls, delta.reasoning_content, and choices[0].finish_reason.
Reproduction
Executed the unmodified handleChatCompletions and translation modules from commit a349b521bb02f76c7b8820c13633a8076dcec378 with a mocked handleResponses boundary that returns application/json. Routing and unrelated runtime integrations are stubbed so the test needs no provider, credentials, or running proxy.
Save the following as repro-json-chat.ts in the repository root, then run it in a separate process with bun run repro-json-chat.ts (the mocks are process-global):
import { mock } from "bun:test";
let upstreamBody: Record<string, unknown>;
mock.module("./src/adapters/openai-responses", () => ({ FORWARD_HEADERS: [] }));
mock.module("./src/lib/retry-after", () => ({ resolveClientRetryAfter: () => undefined }));
mock.module("./src/lib/token-estimate", () => ({ estimateTokens: () => 1 }));
mock.module("./src/router", () => ({
UnknownRoutingPolicyError: class extends Error {},
NoEligiblePolicyCandidateError: class extends Error {},
routeModel: () => ({
providerName: "fixture", modelId: "fixture", codexAccountMode: "direct",
provider: { adapter: "openai-responses" },
}),
}));
mock.module("./src/routing/request-evidence", () => ({ evidenceFromBody: () => ({}) }));
mock.module("./src/server/adapter-resolve", () => ({ resolveWireProtocolOverride: (_name, _model, provider) => provider }));
mock.module("./src/server/request-decompress", () => ({ readJsonRequestBody: (req) => req.json() }));
mock.module("./src/server/request-log", () => ({
addFinalRequestLog() {}, httpStatusForRequestLogTerminal: () => 200, recordFirstOutput() {},
}));
mock.module("./src/server/relay", () => ({ responseWithDeferredRequestLog: (r) => r }));
mock.module("./src/server/responses", () => ({ handleResponses: async () => Response.json(upstreamBody) }));
mock.module("./src/codex/native-main-admission", () => ({ tryClaimNativeMainProfileForTurn: () => false }));
mock.module("./src/server/chat-native", () => ({ isNativeChatRouteEligible: () => false, handleNativeChatCompletions() { throw Error("unexpected native path"); } }));
mock.module("./src/server/effort-row", () => ({ parseRequestEffortRowId: () => null }));
mock.module("./src/server/fast-row", () => ({ parseSyntheticRowId: () => ({}) }));
mock.module("./src/providers/openai-tiers", () => ({ isCanonicalOpenAiForwardProvider: () => false }));
mock.module("./src/codex/loopback-target", () => ({ CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE: "", isCodexReserveHelperUnsupported: () => false }));
const { handleChatCompletions } = await import("./src/server/chat-completions");
const fixtures = {
tool: { status: "completed", output: [{ type: "function_call", call_id: "call_fixture", name: "lookup", arguments: '{"q":"hello"}' }] },
reasoning: { status: "completed", output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "Fixture reasoning." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "Answer." }] }] },
truncated: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Partial answer." }] }] },
};
for (const [name, fixture] of Object.entries(fixtures)) {
upstreamBody = { id: "resp_fixture", ...fixture, usage: { input_tokens: 1, output_tokens: 2 } };
for (const stream of [false, true]) {
const req = new Request("http://localhost/v1/chat/completions", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "fixture", messages: [{ role: "user", content: "Hello" }], stream }),
});
const res = await handleChatCompletions(req, {} as any, {} as any);
const text = await res.text();
if (!stream) {
console.log(name, "JSON", res.status, JSON.stringify(JSON.parse(text).choices[0]));
} else {
const frames = text.split("\n\n").filter(s => s.startsWith("data: {")).map(s => JSON.parse(s.slice(6)));
console.log(name, "SSE", res.status, JSON.stringify(frames.map(f => f.choices[0])));
}
}
}
This exercises six handler calls: three identical upstream fixtures, each with the client's stream flag false and true. The real request handler returns HTTP 200 for all six. It is a handler-level reproduction with injected upstream responses, not a live-provider/full-server integration test.
Version
Upstream dev commit a349b521bb02f76c7b8820c13633a8076dcec378, checked 2026-09-06.
Operating system
Ubuntu 24.04.3 LTS, Linux x64; Bun 1.4.2.
Provider and model
Synthetic Responses upstream; model identifier fixture. Relevant when the internal Responses replay returns JSON to a streaming Chat request.
Logs or error output
Normalized observed differences (generated IDs omitted):
| Fixture |
Client stream=false |
Client stream=true |
| Completed function call |
message.tool_calls[0].function.name = "lookup"; finish tool_calls |
No delta.tool_calls; finish stop |
| Reasoning plus text |
reasoning_content = "Fixture reasoning." and content = "Answer." |
Text remains; no reasoning delta |
| Token-limited partial answer |
content = "Partial answer."; finish length |
Same partial text; finish stop |
For the tool fixture, the entire streamed choice sequence is:
[
{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null},
{"index":0,"delta":{},"finish_reason":"stop"}
]
It ends with [DONE], without ever transmitting the call ID, function name, or arguments.
Screenshots and supporting files
Root cause: src/server/chat-completions.ts, JSON-to-SSE fallback.
- The completed Chat object already exists at line 450.
- Lines 462–465 extract only
message.content.
- Lines 466–470 emit role/text chunks only.
- Line 471 replaces the actual finish reason with the constant
"stop".
Suggested fix: serialize the full supported Chat completion message into indexed deltas and reuse the converted choice's finish reason. Keep usage and one terminal [DONE], with the existing stream-budget/lifecycle requirements. Add focused handler tests for tool-only JSON, multiple tool calls, reasoning plus text, and max-output-token truncation.
Distinct from #3767: that report concerns structured refusal content lost inside src/chat/outbound.ts. Here the JSON-to-Chat conversion successfully preserves the tool/reasoning fields, and the subsequent SSE synthesis in the HTTP handler discards them.
Duplicate checks included issue searches for JSON fallback/upstream and finish_reason, plus PR searches for JSON fallback, JSON upstream + tool, and synthesized Chat streams. No matching fix/report was found; the defect remains in the pinned current-dev source.
Redacted configuration
No real configuration is needed. Routing is a synthetic direct Responses fixture in the harness. All identifiers, text, function names, arguments, and usage values are synthetic.
Checks
Resolved in dev
#3803 (
ac4a7659fd) preserves tools, reasoning, usage and finish semantics in JSON-to-SSE completion delivery.Verified against dev
5759d9ea2f1e7281cdc01eb9628f2e0a123fb59c. Original report by @turin-dev. The attribution record was added in #3811.Client or integration
Direct HTTP/API client
Area
Streaming
Summary
When the Chat endpoint's internal Responses replay returns JSON instead of SSE,
stream: trueenters a lossy JSON-to-SSE fallback. The fallback serializes onlymessage.contentand hard-codesfinish_reason: "stop", even thoughresponsesJsonToChatCompletionhas already produced tool calls, reasoning content, and the correct finish reason.The same upstream JSON produces:
stream: false: the expectedmessage.tool_callsandfinish_reason: "tool_calls".stream: true: no tool-call delta at all; just the assistant role and a successful-lookingstopfollowed by[DONE].A reasoning-plus-text response loses its
reasoning_contentwhile retaining normal text. A response incomplete due tomax_output_tokenschanges fromlengthtostop.Impact: tool-capable clients cannot execute the requested tool, and clients cannot detect token-limit truncation from the terminal chunk. This is specific to the JSON response fallback, not a claim about the usual SSE-to-SSE or native Chat passthrough paths.
Expected: the synthesized stream preserves the semantic fields and terminal reason of the already-converted Chat completion: indexed
delta.tool_calls,delta.reasoning_content, andchoices[0].finish_reason.Reproduction
Executed the unmodified
handleChatCompletionsand translation modules from commita349b521bb02f76c7b8820c13633a8076dcec378with a mockedhandleResponsesboundary that returnsapplication/json. Routing and unrelated runtime integrations are stubbed so the test needs no provider, credentials, or running proxy.Save the following as
repro-json-chat.tsin the repository root, then run it in a separate process withbun run repro-json-chat.ts(the mocks are process-global):This exercises six handler calls: three identical upstream fixtures, each with the client's
streamflag false and true. The real request handler returns HTTP 200 for all six. It is a handler-level reproduction with injected upstream responses, not a live-provider/full-server integration test.Version
Upstream
devcommita349b521bb02f76c7b8820c13633a8076dcec378, checked 2026-09-06.Operating system
Ubuntu 24.04.3 LTS, Linux x64; Bun 1.4.2.
Provider and model
Synthetic Responses upstream; model identifier
fixture. Relevant when the internal Responses replay returns JSON to a streaming Chat request.Logs or error output
Normalized observed differences (generated IDs omitted):
message.tool_calls[0].function.name = "lookup"; finishtool_callsdelta.tool_calls; finishstopreasoning_content = "Fixture reasoning."andcontent = "Answer."content = "Partial answer."; finishlengthstopFor the tool fixture, the entire streamed choice sequence is:
[ {"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}, {"index":0,"delta":{},"finish_reason":"stop"} ]It ends with
[DONE], without ever transmitting the call ID, function name, or arguments.Screenshots and supporting files
Root cause: src/server/chat-completions.ts, JSON-to-SSE fallback.
message.content."stop".Suggested fix: serialize the full supported Chat completion message into indexed deltas and reuse the converted choice's finish reason. Keep usage and one terminal
[DONE], with the existing stream-budget/lifecycle requirements. Add focused handler tests for tool-only JSON, multiple tool calls, reasoning plus text, and max-output-token truncation.Distinct from #3767: that report concerns structured refusal content lost inside
src/chat/outbound.ts. Here the JSON-to-Chat conversion successfully preserves the tool/reasoning fields, and the subsequent SSE synthesis in the HTTP handler discards them.Duplicate checks included issue searches for JSON fallback/upstream and finish_reason, plus PR searches for JSON fallback, JSON upstream + tool, and synthesized Chat streams. No matching fix/report was found; the defect remains in the pinned current-dev source.
Redacted configuration
No real configuration is needed. Routing is a synthetic direct Responses fixture in the harness. All identifiers, text, function names, arguments, and usage values are synthetic.
Checks