Repository navigation
feat(server): continue_final_message on /v1/chat/completions (+ exact max_tokens) - #870
Conversation
A trailing assistant message can now be continued instead of answered, as vLLM's and Hugging Face's continue_final_message do. The chat template is rendered without a generation prompt and cut right after the final message's text (transformers' algorithm), so logprobs give the next-token distribution after a partial answer through the model's own template. - chat_continue: the cut, shared by every model through AutoModel::_render_chat_template; a template that rewrites the message is a 400 invalid_request_error - models opt in via supports_continue_final_message(); in continue mode they keep every prompt token and feed no think preamble. gpt-oss and single-turn models are refused - server: validation (role, tool_calls, images, empty text, JSON / forced tool_choice, add_generation_prompt: true) with 400s - output parser: the continuation keeps its whitespace; reasoning state is read from the continued turn only; on a model that reasons first, a </think> with no <think> closes reasoning the model read the message as Fixes found on the way, all models: - max_tokens N now writes N tokens: the sample taken after prefill was emitted but not counted, so N+1 came out and completion_tokens said N - token_history now matches what the engine ran: a token cut off by max_tokens, cancel or the context cap is no longer recorded as run, and several custom loops now record the first sample they forward - SentencePiece byte tokens (<0x0D>) decode to their byte instead of their name - a single-turn model refuses history with HTTP 400 instead of 200 Co-Authored-By: Claude <noreply@anthropic.com>
- src/test/chat_continue: CPU-only test of the cut on the template shapes FLM ships and of the parser for continued text; runs in the Ubuntu CI - openai_api_test/test_continue_final_message.py: continuation, logprobs, stream parity, cache resume, follow-up turn, 400s - test_logprobs.max_tokens_is_exact - --always-thinks now gives each answer a reasoning budget on top (--reasoning-budget, default 1024): those models used up the answer budgets reasoning, so content, JSON and tool calls never came Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
- continue_final_message keeps transformers' cut wherever the template opens a reply the way it renders a finished message. Where it does not (Qwen3.5 drops the empty think block, Gemma4 12B with thinking off its empty thought channel, LFM2.5-Thinking its opening <think>), the cut read as a finished turn and Qwen3.5 ended at once with empty content; the text now follows the reply's opening. chat_continue_prompt holds the rule and the turn the output parser reads, and is unit tested - _shared_insert (and Qwen3.5-Omni's own insert) read the prompt in again when the engine already holds all of it: the same request twice left nothing to prefill, and the empty prefill crashed hy-mt2-flash once the unrun token no longer sat in the history - parser: whitespace before later text is settled in partial parses too, so a stream with a tool call or think block after the continued text no longer drops a delta; a closed whitespace-only think block is layout - Gemma4e checkpoint history keeps an eos the engine ran; the forced think paths of Qwen3, Qwen3-TK and Gemma4 12B filter and stop on an eos first sample, and count every forced token in the context Co-Authored-By: Claude <noreply@anthropic.com>
At 16 tokens a model that reasons first spent the whole budget reasoning and returned no content to take logprobs from. Co-Authored-By: Claude <noreply@anthropic.com>
NPU hardware results (final build)Run on a Strix Point laptop against Full
The modules this PR changes (continue_final_message / logprobs / response_shape), with a fresh server per module:
Each cell is passed / failed / skipped. Skips are capability skips: no tool syntax, no reasoning, or no cache resume to compare against. * Notes:
|
NPU hardware results: the restThese finish the table above. Each cell is passed / failed / skipped, with one server session per module.
Every model except gpt-oss now has results on the final build, with no failures left. |
…shared_generate now does ROCm#870 counts the token insert() sampled toward generated_tokens and the length limit. A 4093-token prompt in a 4095-token window now reports 3 generated tokens, the engine still running 11 and 12; and a budget of 2 is what leaves 12 sampled but never run, as a budget of 1 did. Co-authored-by: Cursor <cursoragent@cursor.com>
What
continue_final_message(vLLM's field, Hugging Face'sapply_chat_templateflag) onPOST /v1/chat/completions. When the last message is a partial assistant message, the model continues it instead of starting a new reply:{"messages": [ {"role": "user", "content": "Is Paris the capital of France? Answer yes or no."}, {"role": "assistant", "content": "The answer is:"}], "continue_final_message": true, "max_tokens": 1, "logprobs": true, "top_logprobs": 5}On llama3.2:1b the one logprobs entry is the next-token distribution after the prefix:
' Yes'−0.098,' No'−2.97. This scores a classification or multiple-choice answer through the model's own chat template./v1/completionsand the Ollama endpoints are unchanged.How
add_generation_prompt=falseand cut right after the final message's text, using transformers' algorithm (src/common/chat/chat_continue.cpp). The same template gives the same prompt here as in vLLM/HF. Every model renders through one helper,AutoModel::_render_chat_template.supports_continue_final_message().invalid_request_errorwhen:tool_calls, images or audio, or no text;response_formatJSON or atool_choicethat forces a call;add_generation_prompt: trueis sent with the flag, which vLLM also refuses;<think>block). HF raises in this case too.contentis only the continuation, with its whitespace as written (' Yes').logprobscover exactly the continued tokens.</think>. The text before that tag goes toreasoning_content, as vLLM's reasoning parsers do. A stream from such a model holds its output until the tag or the end of the reply.Fixes found on the way (all models, not only continuation)
max_tokenswas off by one. The token sampled after prefill was emitted but not counted, somax_tokens: Nproduced N+1 tokens whileusage.completion_tokensreported N. This is fixed in_shared_generateand in every model's own decode loop, including the MTP speculative loop, which now caps each draft at the tokens left.token_historycould drift from the KV cache.max_tokens, a cancel or the context cap was recorded as run.<0x0D>) were printed as their name. gemma3 wrote a literal<0x0D>before a JSON answer.--always-thinkstest runs gave reasoning models no room to reason before answering, so content, JSON and tool calls never arrived. The harness now adds a reasoning budget (--reasoning-budget, default 1024) to every answer budget. Onmain, qwen3-tk failed 33 tests, nanbeige 17 and lfm2.5-tk 10 for this reason.Tests
src/test/chat_continue: CPU-only checks of the cut on the template shapes FLM ships (Llama 3, ChatML, Qwen3 think, Gemma, DeepSeek, VL typed content) and of the output parser for continued text, streaming included. It is added to the Ubuntu CI as thechat-continuejob.openai_api_test/test_continue_final_message.pycovers:auto, and the 400 cases.test_logprobs.max_tokens_is_exact.Docs
New sections in
request_parameters.md(Continuing an Assistant Message) andopenapi.md(Score the Next Token After a Partial Answer), both labelled as available from v1.0.11. Merging this publishes them on fastflowlm.com straight away.🤖 Generated with Claude Code