Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
49d9f16
[Frontend] Support pre-tokenized input on chat completions
eicherseiji Jul 1, 2026
50e9c0e
[Frontend] Reject multimodal input alongside chat prompt_token_ids
eicherseiji Jul 8, 2026
e94b80d
[Frontend] Keep tool/reasoning constraints on chat token-in path
eicherseiji Jul 8, 2026
9ddf7c3
Merge remote-tracking branch 'origin/main' into chat-token-in-text-out
eicherseiji Jul 9, 2026
07f8ed5
[Frontend] Move chat token-in tests into test_chat_completion.py
eicherseiji Jul 9, 2026
fcf9232
[Frontend] Add chat token-in tests to test_chat_completion.py
eicherseiji Jul 9, 2026
ded0eff
[Frontend] Make chat token-in e2e test robust to greedy nondeterminism
eicherseiji Jul 9, 2026
cbc7d79
[Frontend] Cover streaming token-in on chat completions
eicherseiji Jul 9, 2026
ecd0660
[Frontend] Make chat prompt_token_ids exclusive with messages/options
eicherseiji Jul 9, 2026
cee2b44
[Frontend] Reuse prefill token ids on decode via kv_transfer_params
eicherseiji Jul 9, 2026
f9f64fd
[Frontend] Test Harmony decode-side token reuse
eicherseiji Jul 13, 2026
2b534de
[Docs] Document decode-side token id reuse for disaggregated serving
eicherseiji Jul 23, 2026
5dee5d4
[Docs] Refine decode token-reuse section wording
eicherseiji Jul 23, 2026
d461988
Merge remote-tracking branch 'origin/main' into pr-48145-review
eicherseiji Jul 23, 2026
d94c09a
Merge branch 'main' into chat-token-in-text-out
NickLucche Jul 24, 2026
6afda6f
Merge branch 'main' into chat-token-in-text-out
eicherseiji Jul 27, 2026
987018c
[CI] Exclude decode-side token reuse tests from the Rust frontend
eicherseiji Jul 28, 2026
d4b761b
Merge branch 'main' into chat-token-in-text-out
mergify[bot] Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,92 @@ async def test_empty_grammar(client: openai.AsyncOpenAI, model_name: str) -> Non
],
extra_body={"structured_outputs": {"grammar": ""}},
)


# Decode-side token reuse for disaggregated serving. The router forwards the
# prefill stage's prompt token ids in kv_transfer_params so the decode stage
# skips re-tokenizing.

TOKEN_IN_MESSAGES = [{"role": "user", "content": "Hello, how are you today?"}]
DECODE_MESSAGES = [{"role": "user", "content": "unrelated decode-side text"}]


@pytest.mark.asyncio
async def test_kv_transfer_prompt_token_ids_round_trip(client: openai.AsyncOpenAI):
"""Ids forwarded in kv_transfer_params are used verbatim, skipping tokenize.

The decode request carries different messages, so a response whose
prompt_token_ids match the forwarded ids proves the ids were used rather
than the request's own messages. Generated text is not compared across
requests because vLLM greedy decoding is not bitwise-reproducible.
"""
baseline = await client.chat.completions.create(
model=MODEL_NAME,
messages=TOKEN_IN_MESSAGES,
max_completion_tokens=16,
temperature=0,
extra_body={"return_token_ids": True},
)
reused_ids = baseline.prompt_token_ids
assert reused_ids

decode = await client.chat.completions.create(
model=MODEL_NAME,
messages=DECODE_MESSAGES,
max_completion_tokens=16,
temperature=0,
extra_body={
"kv_transfer_params": {"prompt_token_ids": reused_ids},
"return_token_ids": True,
},
)

# The engine saw the forwarded ids, not the decode request's own messages.
assert decode.prompt_token_ids == reused_ids
# text-out: reuse still yields a detokenized message.
assert decode.choices[0].message.content


@pytest.mark.asyncio
async def test_kv_transfer_prompt_token_ids_streaming(client: openai.AsyncOpenAI):
"""Decode-side token reuse streams chat-formatted text-out."""
baseline = await client.chat.completions.create(
model=MODEL_NAME,
messages=TOKEN_IN_MESSAGES,
max_completion_tokens=16,
temperature=0,
extra_body={"return_token_ids": True},
)
reused_ids = baseline.prompt_token_ids
assert reused_ids

stream = await client.chat.completions.create(
model=MODEL_NAME,
messages=DECODE_MESSAGES,
max_completion_tokens=16,
temperature=0,
stream=True,
extra_body={
"kv_transfer_params": {"prompt_token_ids": reused_ids},
"return_token_ids": True,
},
)

content = ""
delta_token_ids: list[int] = []
first_chunk = True
async for chunk in stream:
if first_chunk:
# prompt_token_ids arrives once, on the first chunk.
assert chunk.prompt_token_ids == reused_ids
first_chunk = False
if not chunk.choices:
continue
if chunk.choices[0].delta.content:
content += chunk.choices[0].delta.content
if tids := getattr(chunk.choices[0], "token_ids", None):
delta_token_ids.extend(tids)

# streamed text-out, reconstructed from deltas, with generated token ids.
assert content
assert delta_token_ids
31 changes: 31 additions & 0 deletions tests/entrypoints/openai/chat_completion/test_serving_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2307,3 +2307,34 @@ async def result_generator():
f"Choice {choice_idx}: expected finish_reason='tool_calls', "
f"got '{reasons[0]}'"
)


def test_make_request_with_harmony_reuses_kv_transfer_prompt_token_ids():
"""The Harmony reuse branch honors ids forwarded in kv_transfer_params.

A GPT-OSS server is impractical to stand up here, so this exercises the
branch directly on a harmony-configured renderer.
"""
engine = MockEngine()
engine.model_config.hf_config = MockHFConfig(model_type="gpt_oss")
models = OpenAIServingModels(engine, BASE_MODEL_PATHS)
online_renderer = _build_online_renderer(engine, models.registry)
assert online_renderer.use_harmony

request = ChatCompletionRequest(
model=MODEL_NAME,
messages=[{"role": "user", "content": "hi"}],
kv_transfer_params={
"prompt_token_ids": [10, 20, 30],
"do_remote_prefill": True,
},
)
conversation, engine_inputs = online_renderer._make_request_with_harmony(request)

assert conversation == []
assert len(engine_inputs) == 1
engine_input = engine_inputs[0]
assert engine_input["type"] == "token"
assert engine_input["prompt_token_ids"] == [10, 20, 30]
# The reuse key is consumed and other kv_transfer_params are preserved.
assert request.kv_transfer_params == {"do_remote_prefill": True}
59 changes: 48 additions & 11 deletions vllm/renderers/online_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@
logger = init_logger(__name__)


def _reused_prompt_token_ids(request: Any) -> list[int] | None:
"""Pop prompt token ids forwarded for decode-side reuse, if any.

Disaggregated serving carries the prefill stage's ids in
``kv_transfer_params`` so the decode stage can skip re-tokenizing. Removing
the key keeps the id list out of the engine's sampling metadata.
"""
kv = getattr(request, "kv_transfer_params", None)
if not isinstance(kv, dict):
return None
return kv.pop("prompt_token_ids", None) or None


class OnlineRenderer:
def __init__(
self,
Expand Down Expand Up @@ -102,6 +115,12 @@ async def render_chat(

Called directly by render_chat_request and delegated to by
OpenAIServingChat.render_chat_request after its engine-aware checks.

Decode-side token reuse (ids forwarded in ``kv_transfer_params``) is
handled deeper, in ``preprocess_chat`` / ``_make_request_with_harmony``,
so it skips only templating and tokenization while tool-choice
validation and ``adjust_request`` still run and the output is
detokenized (text-out).
"""
tokenizer = self.renderer.tokenizer

Expand Down Expand Up @@ -186,6 +205,13 @@ def _make_request_with_harmony(
should_include_tools: bool = True,
):
"""Build Harmony (GPT-OSS) messages and engine prompt from a chat request."""
reuse_ids = _reused_prompt_token_ids(request)
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
if reuse_ids:
# Decode-side token reuse: feed the forwarded ids straight to the
# engine. Harmony has no adjust_request hook to preserve.
engine_input = tokens_input(reuse_ids, cache_salt=request.cache_salt)
return [], [engine_input]
Comment thread
eicherseiji marked this conversation as resolved.

messages: list[OpenAIMessage] = []

# because of issues with pydantic we need to potentially
Expand Down Expand Up @@ -368,17 +394,28 @@ async def preprocess_chat(
default_mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None),
)

(conversation,), (engine_input,) = await renderer.render_chat_async(
[messages],
chat_params,
tok_params,
prompt_extras={
k: v
for k in ("mm_processor_kwargs", "cache_salt")
if (v := getattr(request, k, None)) is not None
},
skip_mm_cache=skip_mm_cache,
)
reuse_ids = _reused_prompt_token_ids(request)
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
if reuse_ids:
# Decode-side token reuse: feed the forwarded ids straight to the
# engine, skipping templating and tokenization. ``messages`` are not
# tokenized, so conversation is empty. The adjust_request tail below
# still runs.
conversation: list[ConversationMessage] = []
engine_input = tokens_input(
reuse_ids, cache_salt=getattr(request, "cache_salt", None)
)
else:
(conversation,), (engine_input,) = await renderer.render_chat_async(
[messages],
chat_params,
tok_params,
prompt_extras={
k: v
for k in ("mm_processor_kwargs", "cache_salt")
if (v := getattr(request, k, None)) is not None
},
skip_mm_cache=skip_mm_cache,
)

# tool parsing is done only if a tool_parser has been set and if
# tool_choice is not "none" (if tool_choice is "none" but a tool_parser
Expand Down
Loading