Skip to content

Commit 6dbff73

Browse files
WindChimeRanbenchislett
authored andcommitted
[Render][Speculator] Add return_loss_mask to render endpoint for training data generation (vllm-project#46846)
Signed-off-by: Ranran Haoran Zhang <ranzhang@redhat.com> Co-authored-by: Benjamin Chislett <chislett.ben@gmail.com>
1 parent be0266b commit 6dbff73

7 files changed

Lines changed: 288 additions & 20 deletions

File tree

tests/entrypoints/serve/render/test_render.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
@pytest.fixture(scope="module")
1616
def server():
17-
args: list[str] = []
17+
args: list[str] = ["--trust-request-chat-template"]
1818

1919
with RemoteLaunchRenderServer(MODEL_NAME, args) as remote_server:
2020
yield remote_server
@@ -369,3 +369,127 @@ async def test_completion_render_multiple_prompts_token_offsets(client):
369369
assert len(offsets) == len(item["token_ids"])
370370
for start, end in offsets:
371371
assert 0 <= start <= end <= len(prompt)
372+
373+
374+
@pytest.mark.asyncio
375+
async def test_chat_completion_render_assistant_tokens_mask_default(client):
376+
"""Without return_assistant_tokens_mask, assistant_tokens_mask should be null."""
377+
response = await client.post(
378+
"/v1/chat/completions/render",
379+
json={
380+
"model": MODEL_NAME,
381+
"messages": [
382+
{"role": "user", "content": "Hello"},
383+
{"role": "assistant", "content": "Hi!"},
384+
{"role": "user", "content": "How are you?"},
385+
],
386+
},
387+
)
388+
389+
assert response.status_code == 200
390+
data = response.json()
391+
assert data.get("assistant_tokens_mask") is None
392+
393+
394+
@pytest.mark.asyncio
395+
async def test_chat_completion_render_assistant_tokens_mask_false(client):
396+
"""Explicitly setting return_assistant_tokens_mask=false gives null."""
397+
response = await client.post(
398+
"/v1/chat/completions/render",
399+
json={
400+
"model": MODEL_NAME,
401+
"messages": [
402+
{"role": "user", "content": "Hello"},
403+
],
404+
"return_assistant_tokens_mask": False,
405+
},
406+
)
407+
408+
assert response.status_code == 200
409+
data = response.json()
410+
assert data.get("assistant_tokens_mask") is None
411+
412+
413+
@pytest.mark.asyncio
414+
async def test_chat_render_assistant_tokens_mask_null_without_gen_tags(
415+
client,
416+
):
417+
"""The tiny test model lacks ``{% generation %}`` tags, so the mask is null."""
418+
response = await client.post(
419+
"/v1/chat/completions/render",
420+
json={
421+
"model": MODEL_NAME,
422+
"messages": [
423+
{"role": "user", "content": "Hello"},
424+
{"role": "assistant", "content": "Hi!"},
425+
],
426+
"return_assistant_tokens_mask": True,
427+
},
428+
)
429+
430+
assert response.status_code == 200
431+
assert response.json().get("assistant_tokens_mask") is None
432+
433+
434+
# A minimal chat template with {% generation %} tags so we can test that
435+
# the mask correctly marks assistant tokens.
436+
_TEMPLATE_WITH_GENERATION = (
437+
"{% for m in messages %}"
438+
"{% if m['role'] == 'user' %}User: {{ m['content'] }}\n"
439+
"{% elif m['role'] == 'assistant' %}"
440+
"{% generation %}Assistant: {{ m['content'] }}\n{% endgeneration %}"
441+
"{% endif %}"
442+
"{% endfor %}"
443+
)
444+
445+
446+
@pytest.mark.asyncio
447+
async def test_chat_completion_render_assistant_tokens_mask_with_generation_tags(
448+
client,
449+
):
450+
"""With a ``{% generation %}``-enabled template, the mask marks assistant
451+
tokens and the masked tokens decode to the assistant content."""
452+
response = await client.post(
453+
"/v1/chat/completions/render",
454+
json={
455+
"model": MODEL_NAME,
456+
"messages": [
457+
{"role": "user", "content": "Hello"},
458+
{"role": "assistant", "content": "Hi!"},
459+
{"role": "user", "content": "Bye"},
460+
],
461+
"chat_template": _TEMPLATE_WITH_GENERATION,
462+
"return_assistant_tokens_mask": True,
463+
},
464+
)
465+
466+
assert response.status_code == 200
467+
data = response.json()
468+
469+
mask = data["assistant_tokens_mask"]
470+
token_ids = data["token_ids"]
471+
assert mask is not None
472+
assert isinstance(mask, list)
473+
assert len(mask) == len(token_ids)
474+
assert all(v in (0, 1) for v in mask)
475+
assert sum(mask) > 0, "mask should mark at least one assistant token"
476+
477+
# Detokenize masked (assistant) and unmasked (non-assistant) tokens
478+
# separately to verify the mask is correct, not just non-empty.
479+
masked_ids = [t for t, m in zip(token_ids, mask, strict=True) if m]
480+
unmasked_ids = [t for t, m in zip(token_ids, mask, strict=True) if not m]
481+
482+
detok = await client.post(
483+
"/detokenize",
484+
json={"model": MODEL_NAME, "tokens": masked_ids},
485+
)
486+
assert detok.status_code == 200
487+
assert "Hi!" in detok.json()["prompt"]
488+
489+
detok_rest = await client.post(
490+
"/detokenize",
491+
json={"model": MODEL_NAME, "tokens": unmasked_ids},
492+
)
493+
assert detok_rest.status_code == 200
494+
assert "Hi!" not in detok_rest.json()["prompt"]
495+
assert "Bye" in detok_rest.json()["prompt"]

vllm/entrypoints/openai/chat_completion/protocol.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,18 @@ class ChatCompletionRequest(OpenAIBaseModel):
407407
),
408408
)
409409

410+
return_assistant_tokens_mask: bool = Field(
411+
default=False,
412+
description=(
413+
"If true, the /render response will include an "
414+
"``assistant_tokens_mask`` field — a per-token list of 0/1 "
415+
"values indicating which tokens were assistant-generated. "
416+
"Requires the chat template to use ``{% generation %}`` "
417+
"tags. When the template does not support it, "
418+
"``assistant_tokens_mask`` will be ``null``."
419+
),
420+
)
421+
410422
cache_salt: str | None = Field(
411423
default=None,
412424
description=(
@@ -520,6 +532,7 @@ def build_chat_params(
520532
extra_kwargs,
521533
),
522534
media_io_kwargs=self.media_io_kwargs,
535+
return_assistant_tokens_mask=bool(self.return_assistant_tokens_mask),
523536
)
524537

525538
def build_tok_params(self, model_config: ModelConfig) -> TokenizeParams:

vllm/entrypoints/serve/disagg/protocol.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ class GenerateRequest(BaseModel):
7575
token_ids: list[int] = Field(min_length=1)
7676
"""The token ids to generate text from."""
7777

78+
assistant_tokens_mask: list[int] | None = None
79+
"""Per-token mask (1 = assistant-generated, 0 = not).
80+
81+
Only populated when the render request sets ``return_assistant_tokens_mask=True``
82+
and the chat template supports ``{% generation %}``.
83+
``None`` when the mask was not requested or could not be computed.
84+
"""
85+
7886
@field_validator("token_ids")
7987
@classmethod
8088
def validate_token_ids(cls, v: list[int]) -> list[int]:

vllm/entrypoints/serve/render/serving.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,33 @@ async def render_chat_request(
127127
)
128128
params = request.to_sampling_params(max_tokens, self.default_sampling_params)
129129

130+
assistant_tokens_mask: list[int] | None = engine_input.get( # type: ignore[assignment]
131+
"assistant_tokens_mask"
132+
)
133+
if assistant_tokens_mask is not None and len(assistant_tokens_mask) != len(
134+
token_ids
135+
):
136+
logger.warning(
137+
"assistant_tokens_mask length (%d) != token_ids length (%d); "
138+
"this can happen with multimodal inputs where "
139+
"placeholder expansion changes the token count. "
140+
"The mask may be positionally misaligned.",
141+
len(assistant_tokens_mask),
142+
len(token_ids),
143+
)
144+
if len(assistant_tokens_mask) < len(token_ids):
145+
assistant_tokens_mask.extend(
146+
[0] * (len(token_ids) - len(assistant_tokens_mask))
147+
)
148+
else:
149+
assistant_tokens_mask = assistant_tokens_mask[: len(token_ids)]
150+
130151
request_id = f"chatcmpl-{random_uuid()}"
131152

132153
return GenerateRequest(
133154
request_id=request_id,
134155
token_ids=token_ids,
156+
assistant_tokens_mask=assistant_tokens_mask,
135157
features=self._extract_mm_features(engine_input),
136158
sampling_params=params,
137159
model=request.model,

vllm/inputs/engine.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ class TokensInput(_InputOptions):
4242
"""Char-level (start, end) offsets per token, propagated from the
4343
renderer's TokensPrompt when offsets were computed."""
4444

45+
assistant_tokens_mask: NotRequired[list[int] | None]
46+
"""Per-token 0/1 mask marking assistant-generated tokens.
47+
Populated when ``return_assistant_tokens_mask=True`` is set on the
48+
render request and the chat template supports ``{% generation %}``."""
49+
4550

4651
def tokens_input(
4752
prompt_token_ids: list[int],
@@ -151,6 +156,11 @@ class MultiModalInput(_InputOptions):
151156
`prompt_token_ids`.
152157
"""
153158

159+
assistant_tokens_mask: NotRequired[list[int] | None]
160+
"""Per-token 0/1 mask marking assistant-generated tokens.
161+
Populated when ``return_assistant_tokens_mask=True`` is set on the
162+
render request and the chat template supports ``{% generation %}``."""
163+
154164

155165
def mm_input(
156166
prompt_token_ids: list[int],

0 commit comments

Comments
 (0)