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-completionsprovider in~/.fx/settings.json - Endpoint:
https://api.minimaxi.com/v1, modelMiniMax-M3(MiniMax) - Command:
fx ask 'What model are you?'
Steps to reproduce
- 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" }
}- Run:
fx ask 'What model are you?'- The answer prints to stdout. The process then exits non-zero:
fx: IncompleteStreamThe 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: 19finish_reasonpresent: yes, valuestopdata: [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_reasonmust bestoportool_callslengthstill maps toerror.OutputTruncatedcontent_filterstill maps toerror.ContentFiltered- refusal, tool name and argument validation still run
- a stream without any
finish_reasonstill returnserror.IncompleteStream
Thanks for the thorough report and wire trace.
I've verified the root cause and implemented this fix locally:
ull\ (EOF) while
educer.phase == .finished, transition
educer.phase = .done\ and return
educer.finish(...).
The branch and commit are ready locally. Happy to open a PR for this if the maintainers would like!