fix(server): emit terminal finish_reason chunk when parsers swallow the finished output - #673
Conversation
…he finished output A finished=True engine output consumed by a parser `continue` in stream_chat_completion (e.g. gemma4 emits the complete tool call in one delta, then a bare <turn|> end-of-turn token as the terminal delta, which the tool parser suppresses) ends the stream with the tool_calls chunk (finish_reason=null) followed by [DONE] and no finish_reason chunk. Strict OpenAI clients (pi coding agent) abort with "Stream ended without finish_reason" and retry the turn. Track whether any emitted chunk carried a *non-null* finish_reason; after the streaming loop, if the engines finished output was suppressed, emit the terminal chunk (finish_reason="tool_calls" when tool calls were detected, else the engine's reason or "stop"). Also cherry-pick the engine-side fix from waybarrios#681 (Thump604) so that SimpleEngine stamps finish_reason="stop" on natural-stop streaming epilogue instead of leaving it null. Refs waybarrios#672
85f444d to
7aaea58
Compare
|
@Thump604 thank you for the thorough review and for catching the tracker bug. I have applied all the requested changes:
Please let me know if anything else needs adjustment. |
|
just updated this branch from main. Ill review it soon, meanwhile ci is running. |
| ), | ||
| ) | ||
| ], | ||
| usage=get_usage(last_output), |
There was a problem hiding this comment.
I found one edge case here: when the engine finishes with finish_reason=None, the normal path already sends usage at the preceding final chunk. Since no finish reason was emitted, this fallback then sends another terminal chunk with the same usage.
That can make clients double-count prompt and completion tokens. Could we ensure usage is emitted only once, ideally on the fallback chunk when it supplies "stop"? A regression assertion that exactly one payload contains usage would cover this.
There was a problem hiding this comment.
Fixed in Thump604/vllm-mlx@7e454a0. Finished outputs with no terminal reason no longer attach usage to the preceding nonterminal chunk; the fallback stop chunk is now the sole usage-bearing payload. The regression asserts exactly one usage payload with the expected prompt/completion totals. Focused terminal matrix: 4 passed; full tests/test_server.py: 126 passed, 3 deselected. The author can cherry-pick 7e454a0.
There was a problem hiding this comment.
Thanks @Thump604 — cherry-picked 7e454a0 onto the PR head (commit f525d2d), attribution preserved. Verified:
- RED: without the usage guard, the new assertion in
test_stream_terminal_finish_reason_when_engine_emits_nonefailsassert 2 == 1— exactly the double-usage payload you diagnosed; the engine-emits-none path attaches usage to the preceding nonterminal chunk and the fallbackstopchunk. - GREEN: with the guard, both affected tests pass; full
tests/test_server.py= 126 passed, 3 deselected, matching your run.ruff/blackclean, no newmypyerrors at the changed lines.
| # after a completed tool call), no chunk carried finish_reason and | ||
| # OpenAI clients abort with "stream ended without finish_reason". | ||
| # Emit the terminal chunk now. | ||
| if ( |
There was a problem hiding this comment.
Could we add coverage for the reasoning parser swallowing the final delta directly? The relevant early continue is at vllm_mlx/server.py#L6172-L6174.
The current tests cover tool-parser suppression, but not the delta_msg is None path. A small regression test should verify that the stream still ends with finish_reason: "stop" and includes the final usage values.
There was a problem hiding this comment.
Added in Thump604/vllm-mlx@7e454a0. The new regression drives the direct reasoning-parser delta_msg is None branch on the finished output and verifies the fallback emits finish_reason="stop" with exactly one final usage payload. Focused terminal matrix: 4 passed; full tests/test_server.py: 126 passed, 3 deselected. The author can cherry-pick 7e454a0.
There was a problem hiding this comment.
Thanks @Thump604 — same cherry-pick as above (f525d2d). The new test_stream_terminal_when_reasoning_parser_swallows_finished_delta drives the reasoning_parser branch where extract_reasoning_streaming returns None on the finished delta (continue), so no normal-path chunk is emitted and the post-loop guard stamps finish_reason="stop" with exactly one usage payload (prompt=5, completion=2, total=7). Passes with the fix; full suite 126 passed, 3 deselected.
|
Applied both review suggestions by cherry-picking Thump604's verified commit
Verification (local, arm64 py3.12 via mise,
|
|
All set. ready to go |
Refs #672
Problem
A streamed tool call can end with the
tool_callschunk (finish_reason: null) followed directly bydata: [DONE]— no chunk ever carriesfinish_reason, and strict OpenAI clients (e.g. the pi coding agent) abort withStream ended without finish_reasonand retry the turn.Root cause: in
stream_chat_completion, the only code attachingfinish_reasonto a chunk lives in the per-output loop. When the engine'sfinished=Trueoutput is consumed by a parsercontinue— e.g. gemma4 emits the complete canonical call (<|tool_call>call:fn{...}<tool_call|>) in one delta, then a bare<turn|>end-of-turn token as the terminal delta, which the tool parser suppresses withcontinue(nothing new to emit) — the terminal chunk is never emitted. There is no post-loop recovery.This is a bug class, not gemma4-specific: any parser
continueon afinished=Trueoutput (tool parser, reasoning parser buffering) loses the stream'sfinish_reason. Non-deterministic in production: bites when the model keeps its thought channel open through the tool call, so the terminal delta travels the reasoning path into the tool parser.Fix
Post-loop terminal-chunk guard: track whether any emitted chunk carried
finish_reason; if the engine's finished output was suppressed, emit the terminal chunk (finish_reason="tool_calls"when tool calls were detected, else the engine's reason or"stop").Tests
Two regression tests in
TestStreamChatCompletion, covering both swallow branches:test_stream_terminal_finish_reason_when_tool_parser_suppresses_eot— plain tool-parser branch, realGemma4ToolParser, exact production delta sequencetest_stream_terminal_finish_reason_when_reasoning_path_suppresses_eot— reasoning-parser branchBoth fail on unpatched main (
assert None == 'tool_calls') and pass with the fix. Fulltests/test_server.pysuite: 121 passed.Live verification
Reproduced against a real server (
gemma-4-31b-it-4bit, production agent payload with 21 tools): unpatched stream endedtool_calls(finish_reason=null)→[DONE]; patched stream endstool_calls→finish_reason: "tool_calls"→[DONE].Related