#1334

Bug: IncompleteStream when an openai-chat-completions server ends the stream after finish_reason without `data: [DONE]`

Openenvoakopened 27d ago

Summary

A custom openai-chat-completions connection reports fx: IncompleteStream on every reply. The full answer prints to stdout first. The failure happens at the end of the stream.

The server sends a final chunk that carries finish_reason, then closes the connection. It never sends data: [DONE].

Environment

  • fx version: v0.0.14, installed with setup.sh
  • Platform: macOS (darwin)
  • Connection: custom openai-chat-completions provider in ~/.fx/settings.json
  • Endpoint: https://api.minimaxi.com/v1, model MiniMax-M3 (MiniMax)
  • Command: fx ask 'What model are you?'

Steps to reproduce

  1. Define a custom connection:
{
  "provider": "minimax",
  "providers": {
    "minimax": {
      "protocol": "openai-chat-completions",
      "base_url": "https://api.minimaxi.com/v1",
      "auth": { "type": "bearer", "env": "MINIMAX_API_KEY" },
      "model_metadata": {
        "MiniMax-M3": {
          "context_window": 131072,
          "max_output_tokens": 8192,
          "supports_tool_use": true
        }
      }
    }
  },
  "models": { "minimax": "MiniMax-M3" }
}
  1. Run:
fx ask 'What model are you?'
  1. The answer prints to stdout. The process then exits non-zero:
fx: IncompleteStream

The prompt content does not matter. Every prompt fails the same way.

Raw wire capture

A direct curl -N to the same endpoint with "stream": true returns HTTP/2 200 and content-type: text/event-stream.

Event 19 of 19, the final event on the wire:

data: {"id":"<redacted>","choices":[{"finish_reason":"stop","index":0,"delta":{"role":"assistant"}}],"created":1792243918,"model":"MiniMax-M3","object":"chat.completion.chunk","usage":null,"service_tier":"standard"}

Then the connection closes.

  • data: events captured: 19
  • finish_reason present: yes, value stop
  • data: [DONE] present: no, the string never appears in the body

The first 18 events carry assistant content. Content is redacted from this report.

Root cause

In src/gateway/chat_completions_protocol.zig, consume_stream:

const chunk = data orelse return error.IncompleteStream;

framing.next() returns null at EOF. The reducer already reached phase == .finished when it accepted the finish_reason chunk (self.phase = .finished; in Reducer.accept). fx still returns error.IncompleteStream, because it waits for a framed [DONE] that this server never sends.

The doc comment on consume_stream states:

Stops exactly at framed [DONE], without waiting for EOF or consuming another event.

This makes [DONE] mandatory. Several OpenAI-compatible servers end a stream by closing the connection instead.

Attribution

The endpoint omits the data: [DONE] sentinel that OpenAI-style streams end with, so the server side is non-conformant. DeepSeek and Qwen document the sentinel in their Chat Completions specs, and major clients treat a missing sentinel as a server defect.

At the same time, most clients treat EOF as end-of-stream instead of requiring the sentinel, so fx is stricter than the ecosystem norm. Since the reducer already tracks finish_reason, accepting EOF at phase == .finished keeps truncation detection intact. A stream cut off before finish_reason still fails.

Expected behavior

A stream that delivered finish_reason and then reached EOF should complete as a normal turn.

A stream cut off before finish_reason must still fail with error.IncompleteStream.

Suggested fix

In consume_stream, treat EOF after a finished choice as a normal end:

const chunk = data orelse {
    if (reducer.phase == .finished) {
        reducer.phase = .done;
        return reducer.finish(cancel_flag.load(.seq_cst));
    }
    return error.IncompleteStream;
};

This keeps every guard inside finish():

  • finish_reason must be stop or tool_calls
  • length still maps to error.OutputTruncated
  • content_filter still maps to error.ContentFiltered
  • refusal, tool name and argument validation still run
  • a stream without any finish_reason still returns error.IncompleteStream

Activity

1
dhruvil-codes27d ago

Thanks for the thorough report and wire trace.

I've verified the root cause and implemented this fix locally:

  1. In \src/gateway/chat_completions_protocol.zig:consume_stream, when \ raming.next()\ yields
    ull\ (EOF) while
    educer.phase == .finished, transition
    educer.phase = .done\ and return
    educer.finish(...).
  2. Any cutoff occurring prior to \ inish_reason\ (e.g. while \phase == .receiving) continues to safely return \error.IncompleteStream.
  3. Added unit tests covering:
    • Streams terminating cleanly at EOF right after \ inish_reason\ without \data: [DONE].
    • Streams terminating cleanly at EOF after \ inish_reason\ followed by usage trailers without \data: [DONE].
    • Streams cut off before \ inish_reason\ failing with \error.IncompleteStream.
  4. Formatted with \zig fmt\ and verified all 83 \chat completions\ test cases pass.

The branch and commit are ready locally. Happy to open a PR for this if the maintainers would like!