Skip to content

[Bug]: Chat JSON-to-SSE fallback drops tool calls/reasoning and overwrites finish_reason with stop #3770

Description

@turin-dev

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

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinglanded-via-maintainerOriginal PR closed after landing via a maintainer merge trainstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions