Repository navigation
Design: how chat logprobs should behave when a reasoning or tool parser is active #719
Description
What happened
On main, /v1/chat/completions ignores logprobs: true (#693). #224 adds logprobs and returns 400 whenever the server runs a reasoning parser or the request enables tool parsing, and leaves the combined case for later. So any deployment of a thinking model (for example Qwen3.8 with --reasoning-parser qwen3) has no chat logprobs, even with enable_thinking: false. Nothing defines yet what the entries should contain when the parsers hide or reshape tokens. This issue proposes rules for that.
Reference behaviour, from the current sources:
- vLLM (
entrypoints/openai/chat_completion/serving.py) builds chat logprobs from all outputtoken_ids, reasoning and tool-call tokens included. When streaming, it attaches each step's entries to whatever chunk that step produces. It drops them only wheninclude_reasoning=false. - SGLang (
entrypoints/openai/serving_chat.py) attaches a step's entries to the first chunk emitted that step (reasoning, tool call or content). When a parser is on, it flushes leftovers in a separate empty-delta chunk. - OpenAI documents
choices[].logprobs.contentas the message content tokens, with a separaterefusallist. Its reasoning models do not return logprobs.
Neither vLLM nor SGLang gives the meaning OpenAI documents. Clients that use logprobs for confidence or scoring of the answer then get reasoning and tool-markup tokens mixed into content.
Minimal reproduction (CPU only). It shows how the parser reshapes text compared with token boundaries, and that the markers are single token ids:
# PYTHONPATH=python MODEL=nvidia/Qwen3.8-Flash-Next-NVFP4 python repro.py
import os
from transformers import AutoTokenizer
from freetoken.server.reasoning_parser import ReasoningParser
tok = AutoTokenizer.from_pretrained(os.environ["MODEL"])
for m in ["<think>", "</think>", "<tool_call>", "</tool_call>", "<|im_end|>"]:
print(f"{m!r:16} -> {tok.encode(m, add_special_tokens=False)}")
text = "Let me think.</think>\n\nUse a <b> tag."
p = ReasoningParser("qwen3", force_reasoning=True)
for i in tok.encode(text, add_special_tokens=False):
piece = tok.decode([i]); r, c = p.parse_stream_chunk(piece)
print(f"{i:>7} {piece!r:12} reasoning={r!r:10} content={c!r}")
print("non-stream", ReasoningParser("qwen3", force_reasoning=True).parse_non_stream(text))'<think>' -> [248068]
'</think>' -> [248069]
'<tool_call>' -> [248058]
'</tool_call>' -> [248059]
'<|im_end|>' -> [248046]
...
13 '.' reasoning='.' content=''
248069 '</think>' reasoning='' content=''
271 '\n\n' reasoning='' content='\n\n'
361 ' <' reasoning='' content=' '
65 'b' reasoning='' content='<b'
...
non-stream ('Let me think.', 'Use a <b> tag.')
Cause (current main, 9b68e00)
- The parsers work on text, not tokens.
server/reasoning_parser.pyholds back a partial marker (_split_trailing_partial, line 217). In the output above, the<of token' <'is sent with the next token.detect_and_parsestrips the text around</think>(lines 134, 153), but the streaming path does not, so\n\nstreams as content. server/generation.pyadds more reshaping on top: the stream loop (lines 705-722) and the tool-text router, which drops whitespace between tool calls (suppress_ws, lines 633-670).- To line entries up with the emitted text, both parsers would have to report character offsets. feat(server): OpenAI-compatible logprobs for chat and legacy completions #224 notes the same: "needs the reasoning-parser interface to expose per-token classification".
- For the qwen3 and qwen3_coder formats, the markers are each exactly one token id. Each sampled id can be classified from the ids alone, without changing the parsers.
Proposed fix
Proposed rules for choices[].logprobs with a parser active:
logprobs.contenthas an entry for each sampled token that becomes visible message content. Reasoning tokens, tool-call tokens, marker tokens (<think>,</think>,<tool_call>,</tool_call>) and special/EOS ids get no entry. A turn that only calls tools hascontent: [].refusalisnull. When a stop string cuts a token, its entry is kept only if part of its text is still visible.top_logprobsstays the model's raw top-k at that position. An alternative such as</think>is fine there.- Streaming and non-streaming give the same list: the
contententries of all chunks, joined in order, equal the non-streaming list. Whitespace that one path strips (after</think>, between tool calls) is left out in both paths. - Each content entry goes with the first content delta sent at or after that token's step. A token whose text the parser holds back therefore goes with the later delta. A hidden token's entry is never attached to a visible delta.
- Entries still held at the end of the stream go on the
finish_reasonchunk, not dropped. - With
enable_thinking: falseandtool_choice: "none", the result is the same as with no parser.
Mechanism: classify each sampled token by its id, with a small state machine that runs alongside the existing text parsers. The states are reasoning, content and tool call, and the marker ids switch between them. The starting state is the same force_reasoning that _make_reasoning_parser uses (generation.py:389-422). Resolve the marker ids once from the tokenizer. Use a marker only if encode(marker) gives exactly one id that decodes back to it. Parser families whose state cannot be read from single marker ids keep the 400 (for example Harmony, where the channel name follows <|channel|> as plain text). One exception needs a signal from the text side: qwen3_coder also accepts a bare <function= with no <tool_call> (function_call_parser.py:1953). When the tool parser starts a call while the classifier is in content, the held entries should be relabelled as tool-call tokens.
Optional: a vLLM-style "all tokens" scope, or a separate reasoning_content logprob list behind a request flag, for clients that want reasoning confidence as well.
Environment
Seen serving nvidia/Qwen3.8-Flash-Next-NVFP4 on an RTX 5080 16 GB; the reproduction above is CPU-only on main 9b68e00.
Activity