Repository navigation
[Frontend] Make finish_reason and Responses item status truthful - #59324
Conversation
4802065 to
8598b63
Compare
Chat completions reported finish_reason="tool_calls" whenever tool_choice="required" ended with an engine "stop", even when no tool call was parsed, and whenever an auto tool call was parsed, even when generation was truncated by max_tokens. The streaming path already guarded both, so the same request answered differently depending on `stream`. The /derender paths carried two more copies of the policy. Introduce one shared rule, resolve_finish_reason(): "tool_calls" only when the choice actually carries tool calls, the engine finish reason is "stop" and tool_choice is not a single named tool (OpenAI documents "stop" for a forced function). Any other engine reason such as "length" is preserved. Use it from streaming and non-streaming chat and from both derender paths. The Anthropic /v1/messages converter derives stop_reason from this value and inherits the fix. The non-harmony Responses path hardcoded status="completed" on every output message and function call, even inside a response whose top-level status is "incomplete" with reason max_output_tokens. Derive the status of the last item of a generation from its finish reason, matching the harmony path. In streaming, carry that status into the final output_item.done event and end an incomplete response with the spec's response.incomplete event instead of response.completed. Fixes vllm-project#53255, vllm-project#58228, vllm-project#57998. Also covers the finish_reason half of Signed-off-by: Flora Feng <4florafeng@gmail.com>
8598b63 to
9341514
Compare
hickeyma
left a comment
There was a problem hiding this comment.
Thanks @sfeng33, this looks good. Having one resolve_finish_reason() for chat and both /derender paths is what I was hoping for. Its good that the returning length for a cut auto tool call matches streaming chat and the Rust frontend.
A few small things, none blocking but good to get them in if possible:
- If the cut lands in reasoning, non-streaming leaves the reasoning item with
status=Nonebut streaming marks itincompletethroughemit_simple_reasoning_done. Harmony doesn't set a status on reasoning, so I'd drop it from the streaming done event. - With
parallel_tool_calls=false, the filter runs beforebuild_response_output_items. So for[complete, cut], the complete call is the one that gets markedincomplete. It's rare and fine to leave but maybe add a comment. - Harmony streams now end with
response.incompletetoo because the terminal event code is shared. That's correct but it's worth a line in the description. - Tests:
- Could you add one for background replay stopping on
response.incomplete? - The batch derender test would fit better in
test_derender_stream.pyusing the existingparsed_derendererfixture, instead of bareMagicMocks intest_derender.py - An
expect_tool_callscolumn in the parametrize would read better than working the expectation out from the input text
- Could you add one for background replay stopping on
|
This pull request has merge conflicts that must be resolved before it can be |
|
@chaunceyjiang Do you mind taking a look when you get a chance? |
|
@sfeng33 Do you mind pushing this along as it will unblock a lot of issues? |
|
/cc @sfeng33 could you resolve the merge conflicts ? |
9341514 to
32ef089
Compare
…ruthful Signed-off-by: sfeng33 <4florafeng@gmail.com>
GenerateResponse is now a tagged union on main, so it has no model_validate. Signed-off-by: sfeng33 <4florafeng@gmail.com>
32ef089 to
2900085
Compare
Without the stop, retrieving a stored stream that ended incomplete waits forever for more events. Signed-off-by: sfeng33 <4florafeng@gmail.com>
|
Thanks @hickeyma, I merged main.
|
|
/ci run |
|
✅ Triggered Buildkite CI #95074 for commit |
hickeyma
left a comment
There was a problem hiding this comment.
Thanks @sfeng33, LGTM.
On point 1, I agree incomplete is the right status for a reasoning item that got cut. My concern is that non-streaming still returns status=None for the same request. A generation cut inside reasoning comes back incomplete when streamed and None
otherwise which is the stream vs non-stream mismatch this PR is fixing elsewhere. It also matters for continuation. should_continue_final_message treats an incomplete reasoning item as something to continue, so the next turn behaves differently depending on which path made the item.
Rather than dropping it from streaming, could non-streaming set it too when reasoning is the last item? In build_response_output_items:
status=last_item_status if not content and not tool_calls else None
That keeps your semantics and makes both paths agree. Happy for it to be a follow-up.
…m-project#59324) Signed-off-by: Flora Feng <4florafeng@gmail.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
Use the upstream finish-reason policy for streaming and non-streaming Chat responses. Keep length visible after parsing a tool call, and report normal tool completion only when a call was actually emitted. Backport the Chat portion of 80dd705, including the streaming guard previously merged in vllm-project#46303. Responses and derender changes from that PR are outside this deployment's Chat path. Validation: 3 serving regressions fail before the fix; all 7 targeted cases pass afterward, including recovered truncated DeepSeek-V4.1 arguments. Broader CPU serving checks: 35 passed, with the same 9 missing-tokenizer/config cache failures reproduced on the unchanged baseline. Changed-file hooks pass. No GPU model run. Original-Pull-Request: vllm-project#59324 Related-Pull-Request: vllm-project#46303 Co-authored-by: Flora Feng <4florafeng@gmail.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com> Co-authored-by: Codex <noreply@openai.com> Signed-off-by: wangyicong <wangyicong@bytedance.com>
Purpose
Fixes #53255, #58228, #57998. Also covers the
finish_reasonhalf of #53363 and #50889.Chat
finish_reasonand Responses itemstatusboth claimed more than what happened:tool_choice="required"ending with enginestopbut zero parsed tool calls returnedfinish_reason="tool_calls"next to an emptytool_callsarray. Anautotool call parsed from a generation cut bymax_tokensreturnedtool_callsinstead oflength, so a client could not tell a completed turn from one cut mid-arguments. The streaming path already guarded both ([Bugfix][Frontend] Keep length finish_reason for max_tokens-truncated streaming tool calls #46303), so the same request answered differently depending onstream. The/derenderchat paths carried two more hand-written copies of the policy, one with the samemax_tokensdefect.ResponseOutputMessageandResponseFunctionToolCallwas hardcodedstatus="completed", even inside a response whose top-levelstatusisincompletewithincomplete_details.reason = max_output_tokens. The harmony path already did this correctly. In streaming the lastresponse.output_item.donesaidcompletedand the stream ended withresponse.completedinstead of the spec'sresponse.incomplete.incompletenow also ends withresponse.incompleteinstead ofresponse.completed.Relation to open PRs
This PR is a superset of the open partial fixes and makes all four sites share one rule:
and bool(tool_calls)to therequiredclause only; leaves thelengthbug and both derender copies.stoponly; leaves therequired-with-zero-calls bug, has conflicts.length) is superseded by the merged [Bugfix][Frontend] Keep length finish_reason for max_tokens-truncated streaming tool calls #46303.resolve_finish_reason().incomplete, streamingclose_current); this PR covers that part and derives it from the same finish reason the chat rule uses. Its separate Harmony zero-delta lifecycle fix is independent and still stands.response.incompleteterminal event; covered here (sameResponseIncompleteEventwrapper and background-replay handling).Test Plan
Live A/B on H200 with
Qwen/Qwen3-0.6B,--enable-auto-tool-choice --tool-call-parser hermes --reasoning-parser qwen3 --max-model-len 4096(plus a second pair of servers with no tool parser), this branch vs merge-basedfc8e0f3e2, 29 + 9 requests (chat non-stream/stream,n=2, Responses non-stream/stream, Anthropic) replayed twice attemperature=0.max_tokens=18cuts the model right after the closing brace of the tool-call JSON, so a call is parsed but the engine reportslength.Test Result
Unit: 21 finish_reason tests and 130 Responses tests pass; full
test_serving_chat.py195 passed (GPT-OSS fixture errors also occur on main).pre-commitclean including mypy.Live A/B, only these fields changed; every other response (complete calls, named, streaming chat, plain text,
n=2, 400s without a parser) was byte-identical:/v1/chat/completions,tool_choice=auto,max_tokens=18(call parsed, cut)finish_reason: "tool_calls""length"stream=true"length""length"(now consistent)/v1/responses,max_output_tokens=6(message cut)status: "completed""incomplete"/v1/responses,tool_choice=required,max_output_tokens=18(call cut)"completed""incomplete"stream=trueoutput_item.done: completed, endsresponse.completedincomplete, endsresponse.incomplete/v1/responses,max_output_tokens=6"completed""incomplete"/v1/messages, auto tool,max_tokens=18stop_reason: "tool_use""max_tokens"required/ named with a complete call"tool_calls"/"stop"