Skip to content

feat(llm): surface logprobs in /v1/chat/completions - #5252

Open
SuperMarioYL wants to merge 5 commits into
xorbitsai:mainfrom
SuperMarioYL:feat/chat-completions-logprobs
Open

feat(llm): surface logprobs in /v1/chat/completions#5252
SuperMarioYL wants to merge 5 commits into
xorbitsai:mainfrom
SuperMarioYL:feat/chat-completions-logprobs

Conversation

@SuperMarioYL

Copy link
Copy Markdown
Contributor

Summary

/v1/chat/completions silently returned logprobs: null even when the client requested logprobs/top_logprobs, while /v1/completions already populated them for vLLM (#5245). The chat builders discarded the logprobs the engine already produced.

Root cause: ChatModelMixin._to_chat_completion / _to_chat_completion_chunk built chat choices without copying the source choice's logprobs, and the chat choice TypedDicts (ChatCompletionChoice, ChatCompletionChunkChoice) did not declare the field at all.

Fix: declare logprobs on both chat choice TypedDicts and propagate choice.get("logprobs") in the two real choice-bearing chat builders. The chat path already receives logprobs-bearing CompletionChunks from the vLLM completions generator (vllm/core.py _convert_request_output_to_completion_chunk_build_logprobs), which also slices per-chunk deltas for streaming (_slice_logprobs), so the chat builder needs no new generation or slicing logic — it only stops discarding what the engine already built.

Follow-up to #5245 (completions logprobs).

Effective scope

vLLM is the only engine that builds logprobs on its chunks today, so this change is effectively vLLM-only: sglang / transformers / llama.cpp / lmdeploy continue to return null until they implement logprobs (tracked as a separate follow-up; the per-engine support matrix is intentionally not addressed here to keep the change minimal and additive). Non-logprobs requests are unaffected — the engine returns None when logprobs are not requested, so propagation is a no-op vs. current behavior.

Shape note

Chat completions carry the legacy CompletionLogprobs shape (text_offset / tokens / token_logprobs / top_logprobs) — the same shape the /v1/completions endpoint already returns — rather than the modern OpenAI chat-logprobs schema (content / bytes). This keeps chat consistent with completions (#5245) and is the smallest correct step; aligning to the modern chat-logprobs schema is left as a follow-up so existing clients reading the completions-style shape keep working.

Tests

Adds two unit tests in xinference/model/llm/tests/test_utils.py:

  • test_to_chat_completion_propagates_logprobs — non-streaming: builds a Completion with a real CompletionLogprobs on its choice, calls ChatModelMixin._to_chat_completion, asserts the chat choice carries the source logprobs (equal, not just not-None).
  • test_to_chat_completion_chunks_propagates_logprobs — streaming counterpart via ChatModelMixin._to_chat_completion_chunks.

Both use the public builder staticmethods/classmethods directly (no mocking of the function under test), are red on main (the builders omit the logprobs key → KeyError/None) and green after this change.

Checklist

  • Adds a regression test that is red on main, green on this branch
  • No public API removal or rename (additive NotRequired field + propagation only)
  • No change to non-logprobs request behavior
  • Scope kept to the chat builders + the two TypedDicts; engine code untouched

The chat completions path silently returned logprobs: null even when
the client requested logprobs/top_logprobs, while /v1/completions
already populated them for vLLM (xorbitsai#5245). The chat builders
(_to_chat_completion, _to_chat_completion_chunk) omitted the field and
the chat choice TypedDicts did not declare it, so the logprobs the
engine already produced were discarded.

Declare logprobs on ChatCompletionChoice / ChatCompletionChunkChoice
and propagate choice.get('logprobs') in the two real choice-bearing
chat builders. The chat path already receives logprobs-bearing
CompletionChunks from the vLLM completions generator (which also slices
per-chunk deltas for streaming), so the chat builder needs no new
generation or slicing logic. Effective scope is vLLM (the only engine
that builds logprobs today); other engines stay null until they
implement logprobs (follow-up).

Adds unit tests asserting chat completions carry the source logprobs,
red on main (field absent) and green after propagation.
@XprobeBot XprobeBot added this to the v3.x milestone Jul 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request ensures that logprobs are correctly propagated from completion choices to chat completion and chat completion chunk choices. It updates the relevant TypedDict definitions in xinference/types.py, includes the logprobs field in the conversion utilities in xinference/model/llm/utils.py, and adds corresponding unit tests in xinference/model/llm/tests/test_utils.py. I have no feedback to provide as there are no review comments.

@qinxuye qinxuye left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two remaining compatibility gaps in the chat logprobs path.

Comment thread xinference/types.py Outdated
Comment thread xinference/model/llm/utils.py Outdated
…ol paths

Chat completions surfaced logprobs in the legacy CompletionLogprobs shape
(text_offset/tokens/token_logprobs/top_logprobs), which openai-python parses
as choice.logprobs.content == None -- standard chat clients still could not
consume the requested logprobs. Add a chat-specific ChatCompletionLogprobs
(content[] of token/bytes/logprob/top_logprobs) and convert the vLLM legacy
shape at the chat builder boundary, for both streaming and non-streaming.

Also preserve the converted logprobs through the tool post-processors:
_post_process_completion previously omitted the field and
_post_process_completion_chunk hard-coded it to None, so tool-enabled
requests lost the generated logprobs even when requested.
@SuperMarioYL

Copy link
Copy Markdown
Contributor Author

Thanks @qinxuye — good catches, both addressed in bf70cc9.

  1. Chat logprobs shape (types.py:207): added a chat-specific ChatCompletionLogprobs (content[] of token/bytes/logprob/top_logprobs) and convert the vLLM legacy parallel-list shape at the chat builder boundary — streaming (_to_chat_completion_chunk, utils.py:591) and non-streaming (_to_chat_completion, utils.py:878). Chat clients now get choice.logprobs.content populated instead of the legacy fields surviving only as extras.

  2. Tool-path preservation (utils.py:816): the converted logprobs now flow through the tool post-processors too — _post_process_completion (utils.py:1062, previously omitted the field) and _post_process_completion_chunk (utils.py:968, previously hard-coded None). Tool-enabled requests keep the generated logprobs when requested.

Tests: updated the two propagation tests to assert the content[] shape and added coverage for both tool post-processors preserving logprobs (using a no-op tool-parser stub).

On CI: the two build_test_job (windows-latest) failures are test_build_subpool_envs_for_virtual_env_enabled (LD_LIBRARY_PATH separator \ vs / on Windows) — in xinference/core/tests/test_utils.py, unrelated to this PR's logprobs changes. The ubuntu/macos/gpu-t4 matrix is green. Happy to dig in if you'd still like it checked, but it doesn't stem from this diff.

@qinxuye qinxuye left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streaming tools path still loses logprobs, and the current test suite fails.

Comment thread xinference/model/llm/utils.py Outdated
_async_to_tool_completion_chunks() converts each legacy CompletionChunk to a
chat chunk via _to_chat_completion_chunk(), which already turns the legacy
parallel-list logprobs into the chat content[] shape. _post_process_completion_chunk()
then re-ran _completion_logprobs_to_chat_logprobs() on that already-converted
value; with no "tokens" key to read, it returned {"content": []} and a real
one-token logprob collapsed to an empty list.

Pass c["choices"][0].get("logprobs") through unchanged there instead. The
non-streaming _post_process_completion() still converts, because it receives a
raw Completion (legacy shape) whose logprobs genuinely need converting.

Update the three non-streaming tool-path tests to expect "logprobs": None now
that the choice carries the field (consistent with the no-tools path), and
rewrite the streaming test to feed a real chat chunk (raw -> _to_chat_completion_chunk
-> post-processor) and assert the one-token logprob survives rather than being
double-converted to {"content": []}.
@SuperMarioYL

Copy link
Copy Markdown
Contributor Author

Thanks for the precise trace — both points addressed in cdf165b.

  1. Streaming tools path loses logprobs (utils.py:971): the double-conversion is gone. _async_to_tool_completion_chunks() already converts each chunk once via _to_chat_completion_chunk(), so _post_process_completion_chunk() now passes c["choices"][0].get("logprobs") through unchanged instead of re-running _completion_logprobs_to_chat_logprobs() (which found no tokens key and collapsed a real logprob to {"content": []}). The non-streaming _post_process_completion() still converts, because it receives a raw Completion whose legacy logprobs genuinely need converting.

  2. Test suite: the three non-streaming tool-path tests now expect "logprobs": None — the choice carries the field, consistent with the no-tools path. The streaming test was rewritten to feed a real chat chunk through the production order and assert a one-token logprob survives.

Local xinference/model/llm/tests/test_utils.py is green (30 passed). CI is re-running on the new commit.

{
"token": token,
"bytes": _token_logprob_bytes(token),
"logprob": logprob,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatCompletionTokenLogprob.logprob is not nullable in the OpenAI chat-completions schema. This converter can emit null here when the legacy token_logprobs entry is None (the new tests explicitly exercise that case). I reproduced the resulting payload failing validation with openai-python 1.99.9: choices.0.logprobs.content.0.logprob: Input should be a valid number. Please avoid emitting an entry with an unknown logprob (or map it to a valid numeric representation), make the chat type non-optional, and add a regression assertion that the response parses through the OpenAI client model.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in b85afa6. ChatCompletionLogprob.logprob is now float (non-optional), and the converter skips tokens whose legacy token_logprobs[i] is None instead of emitting null (xinference/model/llm/utils.py:103-111). content[] now reports only tokens with a known logprob, so no entry fails ChatCompletionTokenLogprob validation. New test_chat_logprobs_parse_openai parses every content[] entry through openai.types.chat.chat_completion_token_logprob.ChatCompletionTokenLogprob (openai-python 1.99.9) — the exact gate that was failing — and the two propagation tests are updated to expect the dropped first token.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The converter fix is correct, but this head is not green yet. pytest -q xinference/model/llm/tests/test_utils.py still fails in test_post_process_completion_preserves_chat_logprobs and test_post_process_completion_chunk_preserves_chat_logprobs: both expected payloads still include the old "Hello" entry with logprob: None, while the converter now correctly omits it. Please update those two expectations to match the known-logprob-only shape. The lint job also fails because isort modifies test_utils.py, so please run isort/pre-commit on the file as well.

…enai model

ChatCompletionTokenLogprob.logprob is non-nullable in the OpenAI chat schema;
openai-python 1.99.9 rejects a null with 'logprob: Input should be a valid
number'. The converter previously copied the legacy token_logprobs[i] (None for
tokens whose logprob was not computed, e.g. the first generated token in the
legacy completion shape) straight into content[].logprob, so the chat response
failed client-side validation.

Drop tokens whose logprob is unknown instead of emitting a null/fabricated
value: content[] reports only tokens whose logprob is actually known. Make
ChatCompletionLogprob.logprob non-optional (float) to match the schema, and add
a regression test that parses every content[] entry through openai-python's
ChatCompletionTokenLogprob.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants