[Feature] Add return_token_ids to /v1/chat/completions and /v1/completions - #22610
[Feature] Add return_token_ids to /v1/chat/completions and /v1/completions#22610ianliuy wants to merge 1 commit into
Conversation
Closes sgl-project#18378 ## What Add an opt-in eturn_token_ids: bool = False parameter to both /v1/chat/completions and /v1/completions endpoints that returns prompt and completion token IDs in the sglext extension field. ## Why For RL training, token consistency between inference and training requires the exact token IDs not just the decoded text (retokenizing text may not reproduce the same IDs). The /generate endpoint already returns output_ids; this feature brings the same capability to the OpenAI-compatible chat/completions endpoints. ## How ### Protocol (protocol.py) - ChatCompletionRequest + CompletionRequest: add eturn_token_ids: bool = False - SglExt: add prompt_token_ids: Optional[List[int]] and completion_token_ids: Optional[List[List[int]]] (_serialize strips None fields no wire-format impact when disabled) ### serving_chat.py - Streaming: accumulate output_ids per choice index (overwrite each chunk output_ids is always cumulative, same as outed_experts) then emit a choices=[] sglext chunk after the stream loop - Non-streaming: read prompt_token_ids from �dapted_request.input_ids (guarded for multimodal), completion_token_ids from et items ### serving_completions.py - Same pattern as serving_chat.py; mirrors the existing symmetry where all eturn_* fields appear in both request classes ### Tests ( est_protocol.py) - 7 new unit tests in TestReturnTokenIds: default value, enable, SglExt field exclusion/inclusion, n>1 choices, coexistence with other SglExt fields, streaming accumulation behavior ## Notes - Zero overhead when disabled (all logic gated behind eturn_token_ids) - Non-OAI-standard fields are placed in sglext per existing convention - Multimodal prompts: prompt_token_ids is set to None (nested input_ids) - Backend unchanged okenizer_manager.py already returns output_ids Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the return_token_ids feature to the OpenAI-compatible Chat and Completion endpoints, allowing users to receive prompt and completion token IDs within the sglext response field. The implementation supports both streaming and non-streaming modes and includes new unit tests for the protocol changes. Review feedback highlights that prompt_token_ids should be returned even if no output tokens are generated. Furthermore, the completions endpoint requires updates to include prompt_token_ids in streaming chunks and to handle string-based prompts by tokenizing them to ensure the requested IDs are correctly populated in the response.
| yield f"data: {routed_experts_chunk.model_dump_json()}\n\n" | ||
|
|
||
| # Send token IDs if requested (covers all choices after generation is complete) | ||
| if request.return_token_ids and output_ids_accum: |
There was a problem hiding this comment.
The check and output_ids_accum prevents returning prompt_token_ids if no output tokens were generated (e.g., when max_tokens=0 or the model returns an empty response). Since prompt_token_ids are available regardless of the generation outcome, this check should be removed to ensure the requested data is always returned.
| if request.return_token_ids and output_ids_accum: | |
| if request.return_token_ids: |
| if request.return_token_ids and output_ids_accum: | ||
| n_choices = request.n or 1 | ||
| completion_token_ids = [ | ||
| output_ids_accum.get(i, []) for i in range(n_choices) | ||
| ] | ||
| token_ids_chunk = CompletionStreamResponse( | ||
| id=content["meta_info"]["id"], | ||
| created=created, | ||
| object="text_completion", | ||
| choices=[], | ||
| model=request.model, | ||
| sglext=SglExt(completion_token_ids=completion_token_ids), | ||
| ) | ||
| yield f"data: {token_ids_chunk.model_dump_json()}\n\n" |
There was a problem hiding this comment.
This streaming chunk is missing the prompt_token_ids field, which creates a discrepancy with the chat endpoint. Additionally, the and output_ids_accum check should be removed to ensure prompt_token_ids are returned even if no output tokens were generated. For completions where the prompt is a string, we should also attempt to retrieve the token IDs from the adapted request's text.
if request.return_token_ids:
n_choices = request.n or 1
prompt_ids = getattr(adapted_request, "input_ids", None)
if prompt_ids is None:
text = getattr(adapted_request, "text", None)
if isinstance(text, str):
prompt_ids = self.tokenizer_manager.tokenizer.encode(text)
# Only include prompt_ids for non-multimodal requests (flat list of ints)
if (
isinstance(prompt_ids, list)
and prompt_ids
and isinstance(prompt_ids[0], list)
):
prompt_ids = None
completion_token_ids = [
output_ids_accum.get(i, []) for i in range(n_choices)
]
token_ids_chunk = CompletionStreamResponse(
id=content["meta_info"]["id"],
created=created,
object="text_completion",
choices=[],
model=request.model,
sglext=SglExt(
completion_token_ids=completion_token_ids,
prompt_token_ids=prompt_ids,
),
)
yield f"data: {token_ids_chunk.model_dump_json()}\n\n"| input_ids = getattr(adapted_request, "input_ids", None) | ||
| if ( | ||
| input_ids is not None | ||
| and len(input_ids) > 0 | ||
| and not isinstance(input_ids[0], list) | ||
| ): | ||
| prompt_token_ids = list(input_ids) |
There was a problem hiding this comment.
For completions requests where the prompt is provided as a string (the most common case), adapted_request.input_ids will be None. This results in prompt_token_ids being omitted from the response even when requested. The prompt should be tokenized if input_ids is missing to ensure the feature works as expected for string prompts.
input_ids = getattr(adapted_request, "input_ids", None)
if input_ids is None:
text = getattr(adapted_request, "text", None)
if isinstance(text, str):
input_ids = self.tokenizer_manager.tokenizer.encode(text)
if (
input_ids is not None
and len(input_ids) > 0
and not isinstance(input_ids[0], list)
):
prompt_token_ids = list(input_ids)|
Up |
|
What do you think about change |
|
+1 would love to get this functionality. However, this should support token IDs for streamed SSE chunks as well. If the streamed token IDs were added in a way that follows the vLLM convention, it may be logical to also follow the convention for the overall completion tokens as @MLKoz2 suggested. For reference, vLLM PR: vllm-project/vllm#22587 |
Motivation
Closes #18378
For RL training workflows, token consistency between inference and training requires the exact token IDs — retokenizing decoded text may not reproduce the same IDs. The
/generateendpoint already returnsoutput_ids; this PR brings the same capability to the OpenAI-compatible chat and completions endpoints.Note: PR #18398 covers the same feature and has been open for 2+ months with no reviews. This is a fresh, minimal implementation that addresses correctness concerns and follows codebase conventions more closely.
Changes
protocol.pyChatCompletionRequest+CompletionRequest: addreturn_token_ids: bool = False(opt-in, off by default)SglExt: addprompt_token_ids: Optional[List[int]]andcompletion_token_ids: Optional[List[List[int]]]_serializealready stripsNonefields → zero wire-format impact when disabledserving_chat.pyoutput_idsper choice (plain overwrite —output_idsis cumulative likerouted_experts), emit onechoices=[]sglext chunk after the stream loopprompt_token_idsfromadapted_request.input_ids(guarded for multimodal),completion_token_idsfromretitemsserving_completions.pyserving_chat.pyreturn_*fields appear in bothCompletionRequestandChatCompletionRequesttest/registered/openai_server/basic/test_protocol.pyTestReturnTokenIdsUsage
Design notes
return_token_ids)sglextper existing conventionprompt_token_idsreturnsNone(nestedinput_idsnot supported)tokenizer_manager.pyalready returnsoutput_idsin every chunkChecklist