[Bugfix][Frontend] Seed derender detokenization from the prompt (#59046)
Signed-off-by: Martin Hickey <martin.hickey@ie.ibm.com>
Martin Hickey committed
Oct 2, 2026 at 04:17 UTC
fcfacc3499b3f45978fc92a1b300f817cd17bb77
9 files changed
+760
-163
docs/serving/online_serving/derenderer.md
+9
-3
@@ -23,6 +23,10 @@ endpoints.
23
24
The derender step needs more than the engine's `token_ids`. It also consumes the original `chat_request`/`completion_request` and `prompt_tokens` carried over from the render step (see [Request format](#request-format)) so the tool and reasoning parsers have the context they need. The chat streaming path additionally needs `prompt_token_ids` (see [Streaming cost](#streaming-cost)) when a parser is configured.
25
26
+All derender endpoints also accept an optional `prompt_token_ids` (the `token_ids` of the `GenerateRequest` returned by `/render`). It's used to seed detokenization from the end of the prompt, the same way the engine does. Without it, the first output token is decoded as if it started a new sequence. On SentencePiece tokenizers (Llama-2, Phi-3) that drops its leading space compared to `/v1/chat/completions` and `/v1/completions`. If it's omitted, derender falls back to the generate response's `prompt_token_ids` (set when the `GenerateRequest` has `return_token_ids`), then to decoding without prompt context.
27
+
28
+Seeding only reads the last few IDs (7 today), so a suffix of the prompt is enough and a long prompt doesn't need to travel back to derender. The exception is streaming chat with a tool or reasoning parser configured. There the parser also reads `prompt_token_ids` to decide whether the prompt left reasoning open and it can look back to any point in the prompt. In this case, send the full list (see [Streaming cost](#streaming-cost)).
29
+
30
## API Reference
31
32
- Chat Completions Derender API (`/v1/chat/completions/derender`)
@@ -96,6 +100,7 @@ The server keeps no state between calls. Everything the next call needs is in `s
100
- **`n > 1` is N streams.** Generate interleaves chunks for different choices and tags each with its `index`. Keep one `stream_state` per index and send each chunk with the state for its index. Each index gets its own `role` delta on its first chunk.
101
- **Send every chunk through, including the last ones.** The finish reason usually arrives with the final tokens. A chunk with a `finish_reason` and no tokens is accepted too and flushes any buffered tool call arguments. With `stream_options: {"include_usage": true}`, generate ends with a usage only chunk (`choices: []`). Send it through derender like any other chunk to get the usage chunk for the response. `usage.prompt_tokens` is the request's `prompt_tokens` if set, otherwise the generate chunk's.
102
- **Send the same context on every call.** `chat_request` and `prompt_token_ids` aren't kept between calls, so they go with every chunk including the usage chunk. Both are required when a tool or reasoning parser is configured. `prompt_token_ids` is the `token_ids` of the `GenerateRequest` returned by `/render`.
103
+- **Send `prompt_token_ids` on the first chunk, even without a parser.** It seeds detokenization so the first token's leading space matches the coupled endpoint (see [Request format](#request-format)). Later chunks already carry that context in `stream_state`.
104
- **Don't forward `[DONE]`.** It marks the end of the generate stream and isn't a chunk.
105
106
Streaming derender resolves `logprobs` on the plain detokenization path, including `token_id:N` placeholders, same as the non-streaming endpoints (see [Streaming state and logprobs](#streaming-state-and-logprobs)). The parser path doesn't resolve them yet and drops them.
@@ -110,7 +115,7 @@ When a tool or reasoning parser is configured, parser internal state (buffered m
115
116
- **Transport**: `output_token_ids` and `output_chunk_lens` round-trip in full in both directions on every call. `output_chunk_lens` has one entry per chunk, which is one per token without speculative decoding. This means O(n) bytes per chunk, O(n²) bytes over a full generation. Bounded by `max_model_len`. `prompt_token_ids` is sent in full on every call too and it isn't trimmed as `output_token_ids` grows. This means that for most of a stream it dominates the per chunk payload. A 100k token prompt with 1k tokens of output means `prompt_token_ids` is ~99% of the request body on every chunk.
117
- **Compute**: replay is O(n) `parse_delta` calls per chunk (O(n²) per generation). `parse_delta` itself is O(n) for parsers that re-scan accumulated text (e.g. Hermes tool-call JSON, DeepSeek-R1 reasoning). The per-generation cost is O(n³) character work, not O(n²). This is a deliberately minimal first implementation with no caching layer.
113
-- The parser path also requires `prompt_token_ids` so `parse_delta` can settle whether the prompt left reasoning open or not. Since parser state can't be carried across calls, it re-scans the full prompt once per chunk.
118
+- The parser path also requires `prompt_token_ids` so `parse_delta` can settle whether the prompt left reasoning open or not. Since parser state can't be carried across calls, it re-scans the full prompt once per chunk. Send the full list and not a suffix because the last reasoning marker can be anywhere in the prompt.
119
- Replay runs off the event loop on the renderer's executor (`renderer_num_workers`, default `1`). Size it for the expected number of concurrent parser configured streams.
120
121
`output_token_ids` and `prompt_token_ids` are both bounded by `max_model_len` but callers streaming long reasoning traces through a parser configured model should expect materially more state transport and CPU cost than the plain detokenization path.
@@ -153,7 +158,7 @@ with httpx.Client(timeout=60.0) as client:
158
generate_request = client.post(
159
f"{RENDER}/v1/chat/completions/render", json=chat_request
160
).json()
156
- prompt_tokens = len(generate_request["token_ids"])
161
+ prompt_token_ids = generate_request["token_ids"]
162
163
# 2. Generate: token IDs -> token IDs (token-in / token-out engine)
164
generate_response = client.post(
@@ -166,7 +171,8 @@ with httpx.Client(timeout=60.0) as client:
171
json={
172
"model": MODEL,
173
"generate_response": generate_response,
169
- "prompt_tokens": prompt_tokens,
174
+ "prompt_tokens": len(prompt_token_ids),
175
+ "prompt_token_ids": prompt_token_ids,
176
"chat_request": chat_request,
177
},
178
).json()
requirements/common.txt
+1
-1
@@ -9,7 +9,7 @@ blake3
9
py-cpuinfo
10
transformers >= 5.16.1
11
huggingface_hub >= 1.31.0 # Required for the `huggingface_hub.utils.httpx` re-export
12
-tokenizers >= 0.21.1 # Required for fast incremental detokenization.
12
+tokenizers >= 0.22.0 # Required for fast incremental detokenization.
13
safetensors >= 0.6.2 # MXFP4/MXFP6 dtype support (F8_E8M0, F4) added in 0.6.0: https://github.com/huggingface/safetensors/pull/611
14
protobuf >= 5.29.6, !=6.30.*, !=6.31.*, !=6.32.*, !=6.33.0.*, !=6.33.1.*, !=6.33.2.*, !=6.33.3.*, !=6.33.4.*; platform_machine != "s390x"
15
protobuf == 7.36.1; platform_machine == "s390x"
tests/entrypoints/scale_out/derender/test_derender.py
+135
@@ -27,6 +27,11 @@ async def client(server):
27
yield http_client
28
29
30
+@pytest.fixture(scope="module")
31
+def tokenizer():
32
+ return get_tokenizer(MODEL_NAME)
33
+
34
+
35
# ---------------------------------------------------------------------------
36
# Helpers
37
# ---------------------------------------------------------------------------
@@ -372,6 +377,58 @@ async def test_derender_chat_model_omitted_resolves_served_name(client):
377
assert response.json()["model"] == MODEL_NAME
378
379
380
+@pytest.mark.asyncio
381
+async def test_derender_chat_leading_space_seeded_from_prompt(client, tokenizer):
382
+ """prompt_token_ids keeps the first token's leading space on this
383
+ Metaspace tokenizer, matching the coupled path."""
384
+ gen_req = await _render_chat(client)
385
+ prompt_token_ids = gen_req["token_ids"]
386
+ output_ids = tokenizer.encode("Hello there, output", add_special_tokens=False)
387
+
388
+ unseeded = await client.post(
389
+ "/v1/chat/completions/derender",
390
+ json={
391
+ "model": MODEL_NAME,
392
+ "generate_response": _make_generate_response(output_ids),
393
+ },
394
+ )
395
+ seeded = await client.post(
396
+ "/v1/chat/completions/derender",
397
+ json={
398
+ "model": MODEL_NAME,
399
+ "generate_response": _make_generate_response(output_ids),
400
+ "prompt_token_ids": prompt_token_ids,
401
+ },
402
+ )
403
+ assert unseeded.status_code == 200
404
+ assert seeded.status_code == 200
405
+ unseeded_content = unseeded.json()["choices"][0]["message"]["content"]
406
+ seeded_content = seeded.json()["choices"][0]["message"]["content"]
407
+
408
+ assert not unseeded_content.startswith(" ")
409
+ assert seeded_content.startswith(" ")
410
+ assert seeded_content.lstrip(" ") == unseeded_content
411
+
412
+
413
+@pytest.mark.asyncio
414
+async def test_derender_chat_oversized_prompt_token_ids_rejected(client):
415
+ """prompt_token_ids longer than max_model_len returns 400."""
416
+ gen_req = await _render_chat(client)
417
+ synthetic_ids = gen_req["token_ids"][:3]
418
+ oversized_prompt_ids = [42] * 1_000_000
419
+
420
+ response = await client.post(
421
+ "/v1/chat/completions/derender",
422
+ json={
423
+ "model": MODEL_NAME,
424
+ "generate_response": _make_generate_response(synthetic_ids),
425
+ "prompt_token_ids": oversized_prompt_ids,
426
+ },
427
+ )
428
+ assert response.status_code == 400
429
+ assert "max_model_len" in response.json()["error"]["message"]
430
+
431
+
432
# ---------------------------------------------------------------------------
433
# Completion derender tests
434
# ---------------------------------------------------------------------------
@@ -486,6 +543,84 @@ async def test_derender_completion_prompt_tokens_length_mismatch(client):
543
assert response.status_code == 400
544
545
546
+@pytest.mark.asyncio
547
+async def test_derender_completion_prompt_token_ids_length_mismatch(client):
548
+ """len(prompt_token_ids) != len(generate_responses) returns 400, the
549
+ same as the existing prompt_tokens length check."""
550
+ gr1 = await _render_completion(client, "Hello")
551
+ ids1 = gr1["token_ids"][:3]
552
+
553
+ response = await client.post(
554
+ "/v1/completions/derender",
555
+ json={
556
+ "model": MODEL_NAME,
557
+ "generate_responses": [
558
+ _make_completion_generate_response(ids1, gr1["request_id"]),
559
+ ],
560
+ "prompt_token_ids": [[1, 2], [3, 4]],
561
+ },
562
+ )
563
+ assert response.status_code == 400
564
+
565
+
566
+@pytest.mark.asyncio
567
+async def test_derender_completion_leading_space_seeded_from_prompt(client, tokenizer):
568
+ """Completions counterpart of
569
+ test_derender_chat_leading_space_seeded_from_prompt."""
570
+ gr1 = await _render_completion(client, "Hello world")
571
+ prompt_token_ids = gr1["token_ids"]
572
+ output_ids = tokenizer.encode("Hello there, output", add_special_tokens=False)
573
+
574
+ unseeded = await client.post(
575
+ "/v1/completions/derender",
576
+ json={
577
+ "model": MODEL_NAME,
578
+ "generate_responses": [
579
+ _make_completion_generate_response(output_ids, gr1["request_id"]),
580
+ ],
581
+ },
582
+ )
583
+ seeded = await client.post(
584
+ "/v1/completions/derender",
585
+ json={
586
+ "model": MODEL_NAME,
587
+ "generate_responses": [
588
+ _make_completion_generate_response(output_ids, gr1["request_id"]),
589
+ ],
590
+ "prompt_token_ids": [prompt_token_ids],
591
+ },
592
+ )
593
+ assert unseeded.status_code == 200
594
+ assert seeded.status_code == 200
595
+ unseeded_text = unseeded.json()["choices"][0]["text"]
596
+ seeded_text = seeded.json()["choices"][0]["text"]
597
+
598
+ assert not unseeded_text.startswith(" ")
599
+ assert seeded_text.startswith(" ")
600
+ assert seeded_text.lstrip(" ") == unseeded_text
601
+
602
+
603
+@pytest.mark.asyncio
604
+async def test_derender_completion_oversized_prompt_token_ids_rejected(client):
605
+ """A prompt_token_ids entry longer than max_model_len returns 400."""
606
+ gr1 = await _render_completion(client, "Hello")
607
+ ids1 = gr1["token_ids"][:3]
608
+ oversized_prompt_ids = [42] * 1_000_000
609
+
610
+ response = await client.post(
611
+ "/v1/completions/derender",
612
+ json={
613
+ "model": MODEL_NAME,
614
+ "generate_responses": [
615
+ _make_completion_generate_response(ids1, gr1["request_id"]),
616
+ ],
617
+ "prompt_token_ids": [oversized_prompt_ids],
618
+ },
619
+ )
620
+ assert response.status_code == 400
621
+ assert "max_model_len" in response.json()["error"]["message"]
622
+
623
+
624
@pytest.mark.asyncio
625
async def test_derender_completion_empty_generate_responses(client):
626
"""Empty generate_responses list returns 400."""
tests/entrypoints/scale_out/derender/test_derender_stream.py
+341
-88
@@ -20,11 +20,17 @@ Tests are split into two layers:
20
import json
21
import threading
22
from concurrent.futures import ThreadPoolExecutor
23
+from unittest.mock import AsyncMock, MagicMock
24
25
+import httpx
26
import pytest
27
import pytest_asyncio
28
+from pydantic import ValidationError
29
+from tokenizers.decoders import DecodeStream
30
31
+import vllm.renderers.online_derenderer as online_derenderer_module
32
from tests.entrypoints.scale_out.derender.utils import stream_chat_derender
33
+from tests.utils import RemoteLaunchRenderServer
34
from vllm.entrypoints.generate.base.protocol import (
35
DeltaFunctionCall,
36
DeltaMessage,
@@ -32,7 +38,17 @@ from vllm.entrypoints.generate.base.protocol import (
38
PerRequestMetrics,
39
SpeculativeDecodingMetrics,
40
)
41
+from vllm.entrypoints.openai.chat_completion.protocol import (
42
+ ChatCompletionRequest,
43
+ ChatCompletionStreamResponse,
44
+)
45
+from vllm.entrypoints.openai.completion.protocol import CompletionRequest
46
+from vllm.entrypoints.scale_out.derender.serving import ServingDerender
47
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
48
+ DerenderChatRequest,
49
+ DerenderChatStreamRequest,
50
+ DerenderCompletionRequest,
51
+ DerenderCompletionStreamRequest,
52
DerenderStreamState,
53
GenerateResponse,
54
GenerateResponseChoice,
@@ -41,6 +57,12 @@ from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
57
)
58
from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo
59
from vllm.parser import Parser
60
+from vllm.renderers.online_derenderer import (
61
+ OnlineDerenderer,
62
+ _decode_params,
63
+ _seed_stream_state,
64
+)
65
+from vllm.tokenizers import get_tokenizer
66
from vllm.utils import random_uuid
67
68
MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
@@ -170,6 +192,7 @@ def _make_stream_chunk(
192
usage: dict | None = None,
193
metrics: PerRequestMetrics | None = None,
194
logprobs: dict | None = None,
195
+ prompt_token_ids: list[int] | None = None,
196
) -> GenerateStreamResponse:
197
"""Build a GenerateStreamResponse SSE chunk."""
198
return GenerateStreamResponse(
@@ -184,6 +207,7 @@ def _make_stream_chunk(
207
],
208
usage=UsageInfo(**usage) if usage else None,
209
metrics=metrics,
210
+ prompt_token_ids=prompt_token_ids,
211
)
212
213
@@ -228,18 +252,12 @@ def _make_usage_chunk(
252
@pytest.fixture(scope="module")
253
def tokenizer():
254
"""Load the tiny tokenizer used across unit tests."""
231
- from vllm.tokenizers import get_tokenizer
232
-
255
return get_tokenizer(MODEL_NAME)
256
257
258
@pytest.fixture(scope="module")
259
def derenderer(tokenizer, request):
260
"""Construct a minimal OnlineDerenderer backed by a stub renderer."""
239
- from unittest.mock import MagicMock
240
-
241
- from vllm.renderers.online_derenderer import OnlineDerenderer
242
-
261
renderer = MagicMock()
262
renderer.get_tokenizer.return_value = tokenizer
263
@@ -273,10 +291,6 @@ def parsed_derenderer(tokenizer):
291
`renderer._executor` must be a real `ThreadPoolExecutor`.
292
`loop.run_in_executor` cannot submit work to a `MagicMock`.
293
"""
276
- from unittest.mock import MagicMock
277
-
278
- from vllm.renderers.online_derenderer import OnlineDerenderer
279
-
294
renderer = MagicMock()
295
renderer.get_tokenizer.return_value = tokenizer
296
renderer._executor = ThreadPoolExecutor(max_workers=2)
@@ -433,6 +447,57 @@ class TestDetokenizeDelta:
447
# Bounded by a small constant independent of len(token_ids)
448
assert max_window <= 32
449
450
+ def test_truncated_multibyte_char_is_held_back(self, derenderer, tokenizer):
451
+ """Output ending mid-character emits no U+FFFD: batch == stream == engine."""
452
+ emoji_ids = tokenizer.encode("🌈", add_special_tokens=False)
453
+ byte_ids = emoji_ids[-4:]
454
+ assert len(byte_ids) == 4
455
+ output_ids = byte_ids[:-1]
456
+
457
+ batch, _ = derenderer._detokenize_delta(
458
+ tokenizer, output_ids, DerenderStreamState()
459
+ )
460
+ streamed = self._chunked(derenderer, tokenizer, [[t] for t in output_ids])
461
+ engine = _decode_stream_expected(tokenizer, [], output_ids)
462
+
463
+ assert "�" not in batch
464
+ assert batch == streamed == engine
465
+
466
+ full, _ = derenderer._detokenize_delta(
467
+ tokenizer, byte_ids, DerenderStreamState()
468
+ )
469
+ assert full.endswith("🌈")
470
+
471
+ @pytest.mark.parametrize(
472
+ ("skip", "spaces", "preserve", "fast", "expected"),
473
+ [
474
+ (True, True, False, True, (True, True)),
475
+ (True, False, False, True, (True, True)),
476
+ (True, False, False, False, (True, False)),
477
+ (False, False, False, True, (False, False)),
478
+ (False, False, True, True, (False, False)),
479
+ (True, False, True, True, (False, False)),
480
+ (True, True, True, True, (False, True)),
481
+ ],
482
+ )
483
+ def test_decode_params_mirror_engine(
484
+ self, monkeypatch, skip, spaces, preserve, fast, expected
485
+ ):
486
+ """Flags match the engine's fast or slow detokenizer for the tokenizer."""
487
+ monkeypatch.setattr(
488
+ online_derenderer_module, "uses_fast_detokenizer", lambda _: fast
489
+ )
490
+ request = CompletionRequest(
491
+ model=MODEL_NAME,
492
+ prompt="x",
493
+ skip_special_tokens=skip,
494
+ spaces_between_special_tokens=spaces,
495
+ )
496
+ assert _decode_params(None, request, preserve_special=preserve) == expected
497
+
498
+ def test_decode_params_default_without_request(self, tokenizer):
499
+ assert _decode_params(tokenizer, None) == (True, True)
500
+
501
def test_n_independent_streams_same_result(self, derenderer, tokenizer):
502
"""N parallel streams with the same token sequence give the same text."""
503
token_ids = tokenizer.encode("parallel streams")[:8]
@@ -451,6 +516,242 @@ class TestDetokenizeDelta:
516
assert results[0] == self._one_shot(tokenizer, token_ids)
517
518
519
+# ---------------------------------------------------------------------------
520
+# Prompt seeded detokenization (matches the engine's primed detokenizer)
521
+# ---------------------------------------------------------------------------
522
+
523
+
524
+def _decode_stream_expected(
525
+ tokenizer, prompt_ids: list[int], output_ids: list[int]
526
+) -> str:
527
+ """Engine-equivalent decode via `DecodeStream`, primed by stepping the
528
+ prompt (`DecodeStream(ids=...)` needs tokenizers >= 0.22)."""
529
+ stream = DecodeStream(skip_special_tokens=True)
530
+ for tid in prompt_ids:
531
+ stream.step(tokenizer.backend_tokenizer, tid)
532
+ return "".join(
533
+ s
534
+ for tid in output_ids
535
+ if (s := stream.step(tokenizer.backend_tokenizer, tid)) is not None
536
+ )
537
+
538
+
539
+@pytest.fixture(scope="module")
540
+def leading_space_ids(tokenizer):
541
+ """(prompt_ids, output_ids, expected) where seeded and unseeded decode
542
+ differ by the leading space."""
543
+ prompt_ids = tokenizer.encode("[INST] Hi [/INST]", add_special_tokens=False)
544
+ output_ids = tokenizer.encode("Hello there, output", add_special_tokens=False)
545
+ expected = _decode_stream_expected(tokenizer, prompt_ids, output_ids)
546
+ unseeded = tokenizer.decode(output_ids, skip_special_tokens=True)
547
+ assert expected.startswith(" ") and not unseeded.startswith(" "), (
548
+ "fixture no longer reproduces the leading-space divergence; "
549
+ "pick a different prompt/output pair"
550
+ )
551
+ return prompt_ids, output_ids, expected
552
+
553
+
554
+class TestSeedStreamState:
555
+ """`_seed_stream_state`: pure helper building the initial decode state."""
556
+
557
+ def test_seeds_prev_tokens_from_prompt_tail(self, tokenizer, leading_space_ids):
558
+ prompt_ids, _, _ = leading_space_ids
559
+ state = _seed_stream_state(tokenizer, prompt_ids, skip_special_tokens=True)
560
+ assert state.prev_tokens
561
+ assert state.read_offset == len(state.prev_tokens)
562
+
563
+ def test_none_prompt_token_ids_returns_empty_state(self, tokenizer):
564
+ assert (
565
+ _seed_stream_state(tokenizer, None, skip_special_tokens=True)
566
+ == DerenderStreamState()
567
+ )
568
+
569
+ def test_empty_prompt_token_ids_returns_empty_state(self, tokenizer):
570
+ assert (
571
+ _seed_stream_state(tokenizer, [], skip_special_tokens=True)
572
+ == DerenderStreamState()
573
+ )
574
+
575
+
576
+class TestPromptSeededLeadingSpace:
577
+ """Every decode entry point keeps the first token's leading space when
578
+ given prompt_token_ids."""
579
+
580
+ @pytest.mark.asyncio
581
+ async def test_chat_batch_seeded_via_explicit_prompt_token_ids(
582
+ self, derenderer, leading_space_ids
583
+ ):
584
+ prompt_ids, output_ids, expected = leading_space_ids
585
+ choices = await derenderer.derender_chat(
586
+ GenerateResponse(
587
+ request_id="t",
588
+ choices=[
589
+ GenerateResponseChoice(
590
+ index=0, token_ids=output_ids, finish_reason="stop"
591
+ )
592
+ ],
593
+ ),
594
+ prompt_token_ids=prompt_ids,
595
+ )
596
+ assert choices[0].message.content == expected
597
+
598
+ @pytest.mark.asyncio
599
+ async def test_chat_batch_seeded_via_generate_response_fallback(
600
+ self, derenderer, leading_space_ids
601
+ ):
602
+ prompt_ids, output_ids, expected = leading_space_ids
603
+ choices = await derenderer.derender_chat(
604
+ GenerateResponse(
605
+ request_id="t",
606
+ choices=[
607
+ GenerateResponseChoice(
608
+ index=0, token_ids=output_ids, finish_reason="stop"
609
+ )
610
+ ],
611
+ prompt_token_ids=prompt_ids,
612
+ ),
613
+ )
614
+ assert choices[0].message.content == expected
615
+
616
+ @pytest.mark.asyncio
617
+ async def test_chat_batch_explicit_takes_precedence_over_fallback(
618
+ self, derenderer, tokenizer, leading_space_ids
619
+ ):
620
+ prompt_ids, output_ids, expected = leading_space_ids
621
+ wrong_ids = tokenizer.encode("a different prompt", add_special_tokens=False)
622
+ choices = await derenderer.derender_chat(
623
+ GenerateResponse(
624
+ request_id="t",
625
+ choices=[
626
+ GenerateResponseChoice(
627
+ index=0, token_ids=output_ids, finish_reason="stop"
628
+ )
629
+ ],
630
+ prompt_token_ids=wrong_ids,
631
+ ),
632
+ prompt_token_ids=prompt_ids,
633
+ )
634
+ assert choices[0].message.content == expected
635
+
636
+ @pytest.mark.asyncio
637
+ async def test_chat_batch_without_prompt_context_stays_unseeded(
638
+ self, derenderer, leading_space_ids
639
+ ):
640
+ _, output_ids, _ = leading_space_ids
641
+ choices = await derenderer.derender_chat(
642
+ GenerateResponse(
643
+ request_id="t",
644
+ choices=[
645
+ GenerateResponseChoice(
646
+ index=0, token_ids=output_ids, finish_reason="stop"
647
+ )
648
+ ],
649
+ ),
650
+ )
651
+ assert not choices[0].message.content.startswith(" ")
652
+
653
+ @pytest.mark.asyncio
654
+ async def test_completion_batch_seeded(self, derenderer, leading_space_ids):
655
+ prompt_ids, output_ids, expected = leading_space_ids
656
+ choices, _, _ = await derenderer.derender_completion(
657
+ [
658
+ GenerateResponse(
659
+ request_id="t",
660
+ choices=[
661
+ GenerateResponseChoice(
662
+ index=0, token_ids=output_ids, finish_reason="stop"
663
+ )
664
+ ],
665
+ )
666
+ ],
667
+ prompt_token_ids=[prompt_ids],
668
+ )
669
+ assert choices[0].text == expected
670
+
671
+ @pytest.mark.asyncio
672
+ @pytest.mark.parametrize("from_generate_chunk", [False, True])
673
+ async def test_chat_stream_first_chunk_seeded(
674
+ self, derenderer, leading_space_ids, from_generate_chunk
675
+ ):
676
+ prompt_ids, output_ids, expected = leading_space_ids
677
+ mid = len(output_ids) // 2
678
+
679
+ chunk1, state = await derenderer.derender_chat_stream(
680
+ model=MODEL_NAME,
681
+ generate_chunk=_make_stream_chunk(
682
+ output_ids[:mid],
683
+ prompt_token_ids=prompt_ids if from_generate_chunk else None,
684
+ ),
685
+ prompt_token_ids=None if from_generate_chunk else prompt_ids,
686
+ )
687
+ # Only needed on the first chunk; the carried state already holds
688
+ # the seeded window from here on.
689
+ chunk2, _ = await derenderer.derender_chat_stream(
690
+ model=MODEL_NAME,
691
+ generate_chunk=_make_stream_chunk(output_ids[mid:], finish_reason="stop"),
692
+ state=state,
693
+ )
694
+
695
+ streamed = (chunk1.choices[0].delta.content or "") + (
696
+ chunk2.choices[0].delta.content or ""
697
+ )
698
+ assert streamed == expected
699
+
700
+ @pytest.mark.asyncio
701
+ @pytest.mark.parametrize("from_generate_chunk", [False, True])
702
+ async def test_completion_stream_first_chunk_seeded(
703
+ self, derenderer, leading_space_ids, from_generate_chunk
704
+ ):
705
+ prompt_ids, output_ids, expected = leading_space_ids
706
+ mid = len(output_ids) // 2
707
+
708
+ chunk1, state = await derenderer.derender_completion_stream(
709
+ model=MODEL_NAME,
710
+ generate_chunk=_make_stream_chunk(
711
+ output_ids[:mid],
712
+ prompt_token_ids=prompt_ids if from_generate_chunk else None,
713
+ ),
714
+ prompt_token_ids=None if from_generate_chunk else prompt_ids,
715
+ )
716
+ chunk2, _ = await derenderer.derender_completion_stream(
717
+ model=MODEL_NAME,
718
+ generate_chunk=_make_stream_chunk(output_ids[mid:], finish_reason="stop"),
719
+ state=state,
720
+ )
721
+
722
+ assert chunk1.choices[0].text + chunk2.choices[0].text == expected
723
+
724
+ @pytest.mark.asyncio
725
+ async def test_parser_path_ephemeral_state_seeded(
726
+ self, parsed_derenderer, monkeypatch, leading_space_ids
727
+ ):
728
+ """The parser path's per-call detok window is seeded too, so
729
+ `parse_delta` sees the same text as coupled serving."""
730
+
731
+ class _EchoParser(Parser):
732
+ def parse_delta(
733
+ self,
734
+ delta_text,
735
+ delta_token_ids,
736
+ request,
737
+ prompt_token_ids=None,
738
+ *,
739
+ finished,
740
+ ):
741
+ return DeltaMessage(content=delta_text) if delta_text else None
742
+
743
+ monkeypatch.setattr(parsed_derenderer, "parser", _EchoParser)
744
+ prompt_ids, output_ids, expected = leading_space_ids
745
+
746
+ chunk, _ = await parsed_derenderer.derender_chat_stream(
747
+ model=MODEL_NAME,
748
+ generate_chunk=_make_stream_chunk(output_ids, finish_reason="stop"),
749
+ chat_request=_chat_request(),
750
+ prompt_token_ids=prompt_ids,
751
+ )
752
+ assert chunk.choices[0].delta.content == expected
753
+
754
+
755
@pytest.mark.asyncio
756
@pytest.mark.parametrize(
757
"stream_method",
@@ -460,8 +761,6 @@ async def test_streaming_detokenization_runs_off_event_loop(
761
derenderer, monkeypatch, stream_method
762
):
763
"""Streaming detokenization runs on the renderer executor."""
463
- import vllm.renderers.online_derenderer as online_derenderer_module
464
-
764
event_loop_thread_id = threading.get_ident()
765
executor_thread_id = derenderer.renderer._executor.submit(
766
threading.get_ident
@@ -561,8 +860,6 @@ class TestDerenderCompletionStream:
860
@pytest.mark.asyncio
861
async def test_skip_special_tokens_threaded(self, derenderer, tokenizer):
862
"""completion_request.skip_special_tokens is honored (not hardcoded True)."""
564
- from vllm.entrypoints.openai.completion.protocol import CompletionRequest
565
-
863
eos = tokenizer.eos_token_id
864
if eos is None:
865
pytest.skip("tokenizer has no eos token to exercise special stripping")
@@ -789,8 +1086,6 @@ class TestDerenderChatStream:
1086
1087
1088
def _chat_request(**kwargs):
792
- from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
793
-
1089
kwargs.setdefault("messages", [{"role": "user", "content": "hi"}])
1090
kwargs.setdefault("model", MODEL_NAME)
1091
return ChatCompletionRequest(**kwargs)
@@ -1072,8 +1367,6 @@ def harmony_encode():
1367
@pytest.fixture(scope="module")
1368
def harmony_tokenizer():
1369
pytest.importorskip("openai_harmony")
1075
- from vllm.tokenizers import get_tokenizer
1076
-
1370
return get_tokenizer(HARMONY_MODEL, trust_remote_code=True)
1371
1372
@@ -1085,10 +1378,7 @@ def harmony_derenderer(harmony_tokenizer):
1378
with a real tokenizer and parser, so the replay path is exercised
1379
against Harmony's actual channel grammar rather than a stub.
1380
"""
1088
- from unittest.mock import MagicMock
1089
-
1381
from vllm.parser.harmony import HarmonyParser
1091
- from vllm.renderers.online_derenderer import OnlineDerenderer
1382
1383
renderer = MagicMock()
1384
renderer.get_tokenizer.return_value = harmony_tokenizer
@@ -1224,20 +1514,14 @@ class TestDerenderStreamStateValidation:
1514
"""DerenderStreamState rejects malformed caller supplied offsets/lengths."""
1515
1516
def test_negative_prefix_offset_rejected(self):
1227
- from pydantic import ValidationError
1228
-
1517
with pytest.raises(ValidationError):
1518
DerenderStreamState(prefix_offset=-1)
1519
1520
def test_negative_read_offset_rejected(self):
1233
- from pydantic import ValidationError
1234
-
1521
with pytest.raises(ValidationError):
1522
DerenderStreamState(read_offset=-1)
1523
1524
def test_prev_tokens_over_cap_rejected(self):
1239
- from pydantic import ValidationError
1240
-
1525
with pytest.raises(ValidationError):
1526
DerenderStreamState(prev_tokens=["a"] * 1025)
1527
@@ -1246,14 +1530,10 @@ class TestDerenderStreamStateValidation:
1530
assert len(state.prev_tokens) == 1024
1531
1532
def test_output_chunk_lens_mismatch_rejected(self):
1249
- from pydantic import ValidationError
1250
-
1533
with pytest.raises(ValidationError):
1534
DerenderStreamState(output_token_ids=[1, 2, 3], output_chunk_lens=[1, 1])
1535
1536
def test_output_chunk_lens_zero_entry_rejected(self):
1255
- from pydantic import ValidationError
1256
-
1537
with pytest.raises(ValidationError):
1538
DerenderStreamState(output_token_ids=[1, 2], output_chunk_lens=[0, 2])
1539
@@ -1264,14 +1544,40 @@ class TestDerenderStreamStateValidation:
1544
assert state.output_chunk_lens == [1, 2]
1545
1546
1547
+_GEN = {"request_id": "t", "choices": []}
1548
+_GEN_BAD = {**_GEN, "prompt_token_ids": [-1]}
1549
+
1550
+
1551
+@pytest.mark.parametrize(
1552
+ "model_cls,payload",
1553
+ [
1554
+ (DerenderChatRequest, {"generate_response": _GEN, "prompt_token_ids": [-1]}),
1555
+ (DerenderChatRequest, {"generate_response": _GEN_BAD}),
1556
+ (
1557
+ DerenderCompletionRequest,
1558
+ {"generate_responses": [_GEN], "prompt_token_ids": [[-1]]},
1559
+ ),
1560
+ (
1561
+ DerenderChatStreamRequest,
1562
+ {"stream": True, "generate_chunk": _GEN, "prompt_token_ids": [-1]},
1563
+ ),
1564
+ (
1565
+ DerenderCompletionStreamRequest,
1566
+ {"stream": True, "generate_chunk": _GEN_BAD},
1567
+ ),
1568
+ ],
1569
+)
1570
+def test_negative_prompt_token_ids_rejected(model_cls, payload):
1571
+ """Negative prompt ids would reach convert_ids_to_tokens, so they get a
1572
+ 400 at parse time like `GenerateRequest.token_ids` does."""
1573
+ with pytest.raises(ValidationError, match="prompt_token_ids"):
1574
+ model_cls.model_validate(payload)
1575
+
1576
+
1577
class TestServingDerenderStreamErrorHandling:
1578
"""Malformed stream_state must surface as 400 and not an unhandled 500."""
1579
1580
def _make_serving(self, side_effect: Exception):
1271
- from unittest.mock import AsyncMock, MagicMock
1272
-
1273
- from vllm.entrypoints.scale_out.derender.serving import ServingDerender
1274
-
1581
models = MagicMock()
1582
models.is_base_model.return_value = True
1583
models.model_config = MagicMock()
@@ -1289,10 +1595,6 @@ class TestServingDerenderStreamErrorHandling:
1595
@pytest.mark.asyncio
1596
@pytest.mark.parametrize("exc", [KeyError("bad byte"), IndexError("oob")])
1597
async def test_completion_stream_bad_state_returns_400(self, exc):
1292
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1293
- DerenderCompletionStreamRequest,
1294
- )
1295
-
1598
serving = self._make_serving(exc)
1599
request = DerenderCompletionStreamRequest(
1600
stream=True,
@@ -1307,10 +1609,6 @@ class TestServingDerenderStreamErrorHandling:
1609
@pytest.mark.asyncio
1610
@pytest.mark.parametrize("exc", [KeyError("bad byte"), IndexError("oob")])
1611
async def test_chat_stream_bad_state_returns_400(self, exc):
1310
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1311
- DerenderChatStreamRequest,
1312
- )
1313
-
1612
serving = self._make_serving(exc)
1613
request = DerenderChatStreamRequest(
1614
stream=True,
@@ -1329,13 +1627,6 @@ class TestServingDerenderStreamValidation:
1627
before touching the tokenizer)."""
1628
1629
def _make_serving(self, *, parser_configured: bool, max_model_len: int = 100_000):
1332
- from unittest.mock import AsyncMock, MagicMock
1333
-
1334
- from vllm.entrypoints.openai.chat_completion.protocol import (
1335
- ChatCompletionStreamResponse,
1336
- )
1337
- from vllm.entrypoints.scale_out.derender.serving import ServingDerender
1338
-
1630
models = MagicMock()
1631
models.is_base_model.return_value = True
1632
models.model_config = MagicMock()
@@ -1353,10 +1644,6 @@ class TestServingDerenderStreamValidation:
1644
1645
@pytest.mark.asyncio
1646
async def test_missing_chat_request_with_parser_rejected(self):
1356
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1357
- DerenderChatStreamRequest,
1358
- )
1359
-
1647
serving = self._make_serving(parser_configured=True)
1648
request = DerenderChatStreamRequest(
1649
stream=True,
@@ -1371,10 +1658,6 @@ class TestServingDerenderStreamValidation:
1658
1659
@pytest.mark.asyncio
1660
async def test_missing_chat_request_without_parser_ok(self):
1374
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1375
- DerenderChatStreamRequest,
1376
- )
1377
-
1661
serving = self._make_serving(parser_configured=False)
1662
request = DerenderChatStreamRequest(
1663
stream=True,
@@ -1390,10 +1673,6 @@ class TestServingDerenderStreamValidation:
1673
the same way it rejects a missing chat_request. Without it,
1674
parse_delta cannot tell whether the prompt left reasoning open and
1675
would misclassify reasoning content as plain content."""
1393
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1394
- DerenderChatStreamRequest,
1395
- )
1396
-
1676
serving = self._make_serving(parser_configured=True)
1677
request = DerenderChatStreamRequest(
1678
stream=True,
@@ -1409,10 +1688,6 @@ class TestServingDerenderStreamValidation:
1688
1689
@pytest.mark.asyncio
1690
async def test_prompt_token_ids_present_with_parser_ok(self):
1412
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1413
- DerenderChatStreamRequest,
1414
- )
1415
-
1691
serving = self._make_serving(parser_configured=True)
1692
request = DerenderChatStreamRequest(
1693
stream=True,
@@ -1426,10 +1701,6 @@ class TestServingDerenderStreamValidation:
1701
1702
@pytest.mark.asyncio
1703
async def test_oversized_output_token_ids_rejected(self):
1429
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1430
- DerenderChatStreamRequest,
1431
- )
1432
-
1704
serving = self._make_serving(parser_configured=False, max_model_len=4)
1705
request = DerenderChatStreamRequest(
1706
stream=True,
@@ -1451,10 +1722,6 @@ class TestServingDerenderStreamValidation:
1722
parser configured deployment rescans it in full on every chunk
1723
(is_reasoning_end / adjust_initial_state_from_prompt), so it must be
1724
bounded the same way output_token_ids is."""
1454
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1455
- DerenderChatStreamRequest,
1456
- )
1457
-
1725
serving = self._make_serving(parser_configured=True, max_model_len=4)
1726
request = DerenderChatStreamRequest(
1727
stream=True,
@@ -1475,10 +1742,6 @@ class TestServingDerenderStreamValidation:
1742
DerenderStreamState is threaded through every choice). The check
1743
could never fire since derender_chat_stream itself rejects anything
1744
above 1 first."""
1478
- from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
1479
- DerenderChatStreamRequest,
1480
- )
1481
-
1745
serving = self._make_serving(parser_configured=False)
1746
two_choices = GenerateStreamResponse(
1747
request_id="t",
@@ -1503,16 +1766,12 @@ class TestServingDerenderStreamValidation:
1766
1767
@pytest.fixture(scope="module")
1768
def server():
1506
- from tests.utils import RemoteLaunchRenderServer
1507
-
1769
with RemoteLaunchRenderServer(MODEL_NAME, []) as remote_server:
1770
yield remote_server
1771
1772
1773
@pytest_asyncio.fixture
1774
async def client(server):
1514
- import httpx
1515
-
1775
async with httpx.AsyncClient(
1776
base_url=server.url_for(""), timeout=30.0
1777
) as http_client:
@@ -1776,8 +2035,6 @@ _PARSER_TOOLS = [
2035
2036
@pytest.fixture(scope="module")
2037
def parser_server():
1779
- from tests.utils import RemoteLaunchRenderServer
1780
-
2038
args = [
2039
"--enable-auto-tool-choice",
2040
"--tool-call-parser",
@@ -1791,8 +2048,6 @@ def parser_server():
2048
2049
@pytest_asyncio.fixture
2050
async def parser_client(parser_server):
1794
- import httpx
1795
-
2051
async with httpx.AsyncClient(
2052
base_url=parser_server.url_for(""), timeout=60.0
2053
) as http_client:
@@ -1801,8 +2056,6 @@ async def parser_client(parser_server):
2056
2057
@pytest.fixture(scope="module")
2058
def parser_tokenizer():
1804
- from vllm.tokenizers import get_tokenizer
1805
-
2059
return get_tokenizer(PARSER_MODEL)
2060
2061
vllm/entrypoints/scale_out/derender/serving.py
+32
-1
@@ -117,6 +117,20 @@ class ServingDerender(BaseServing):
117
118
return None
119
120
+ def _validate_prompt_token_ids(
121
+ self,
122
+ prompt_token_ids: list[list[int] | None],
123
+ ) -> ErrorResponse | None:
124
+ """Reject caller-supplied prompt_token_ids longer than max_model_len."""
125
+ max_model_len = self.model_config.max_model_len
126
+ for ids in prompt_token_ids:
127
+ if ids is not None and len(ids) > max_model_len:
128
+ return self.create_error_response(
129
+ f"prompt_token_ids length ({len(ids)}) exceeds "
130
+ f"max_model_len ({max_model_len})."
131
+ )
132
+ return None
133
+
134
async def derender_chat_response(
135
self,
136
request: DerenderChatRequest,
@@ -138,6 +152,10 @@ class ServingDerender(BaseServing):
152
if bounds_error is not None:
153
return bounds_error
154
155
+ prompt_ids_error = self._validate_prompt_token_ids([request.prompt_token_ids])
156
+ if prompt_ids_error is not None:
157
+ return prompt_ids_error
158
+
159
if self.online_derenderer.parser is not None and request.chat_request is None:
160
return self.create_error_response(
161
"chat_request is required when a tool or reasoning parser is "
@@ -147,7 +165,9 @@ class ServingDerender(BaseServing):
165
166
try:
167
choices = await self.online_derenderer.derender_chat(
150
- request.generate_response, request.chat_request
168
+ request.generate_response,
169
+ request.chat_request,
170
+ request.prompt_token_ids,
171
)
172
except ValueError as exc:
173
return self.create_error_response(str(exc))
@@ -203,6 +223,11 @@ class ServingDerender(BaseServing):
223
if bounds_error is not None:
224
return bounds_error
225
226
+ if request.prompt_token_ids is not None:
227
+ prompt_ids_error = self._validate_prompt_token_ids(request.prompt_token_ids)
228
+ if prompt_ids_error is not None:
229
+ return prompt_ids_error
230
+
231
(
232
choices,
233
total_prompt_tokens,
@@ -211,6 +236,7 @@ class ServingDerender(BaseServing):
236
request.generate_responses,
237
request.prompt_tokens,
238
completion_request=request.completion_request,
239
+ prompt_token_ids=request.prompt_token_ids,
240
)
241
242
first = request.generate_responses[0]
@@ -367,6 +393,10 @@ class ServingDerender(BaseServing):
393
if error_check_ret is not None:
394
return error_check_ret
395
396
+ prompt_ids_error = self._validate_prompt_token_ids([request.prompt_token_ids])
397
+ if prompt_ids_error is not None:
398
+ return prompt_ids_error
399
+
400
model_name = request.model or self.models.model_name()
401
try:
402
(
@@ -378,6 +408,7 @@ class ServingDerender(BaseServing):
408
state=request.stream_state,
409
prompt_tokens=request.prompt_tokens,
410
completion_request=request.completion_request,
411
+ prompt_token_ids=request.prompt_token_ids,
412
)
413
except ValueError as exc:
414
return self.create_error_response(str(exc))
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+43
-5
@@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal, TypeAlias
5
from pydantic import (
6
BaseModel,
7
Field,
8
+ NonNegativeInt,
9
PrivateAttr,
10
field_validator,
11
model_validator,
@@ -362,7 +363,7 @@ class GenerateStreamResponse(BaseModel):
363
)
364
choices: list[GenerateResponseStreamChoice]
365
usage: UsageInfo | None = Field(default=None)
365
- prompt_token_ids: list[int] | None = None
366
+ prompt_token_ids: list[NonNegativeInt] | None = None
367
mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
368
metrics: PerRequestMetrics | None = None
369
@@ -382,7 +383,7 @@ class GenerateResponse(BaseModel):
383
usage: UsageInfo | None = Field(default=None)
384
prompt_logprobs: list[dict[int, Logprob] | None] | None = None
385
prompt_token_id_logprobs: str | None = None
385
- prompt_token_ids: list[int] | None = None
386
+ prompt_token_ids: list[NonNegativeInt] | None = None
387
mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
388
metrics: PerRequestMetrics | None = None
389
@@ -421,6 +422,15 @@ class DerenderChatRequest(BaseModel):
422
len(GenerateRequest.token_ids) from the render step.
423
"""
424
425
+ prompt_token_ids: list[NonNegativeInt] | None = None
426
+ """Prompt token IDs (`GenerateRequest.token_ids` from /render). Seeds
427
+ detokenization from the prompt tail so the first output token keeps its
428
+ leading space on SentencePiece tokenizers. Falls back to
429
+ `generate_response.prompt_token_ids`, then to unseeded decoding.
430
+
431
+ Only the last few IDs are read, so a suffix of the prompt is enough.
432
+ """
433
+
434
chat_request: ChatCompletionRequest | None = None
435
"""The original (post-adjust_request) ChatCompletionRequest from /render.
436
@@ -455,6 +465,14 @@ class DerenderCompletionRequest(BaseModel):
465
If provided, len(prompt_tokens) must equal len(generate_responses).
466
"""
467
468
+ prompt_token_ids: list[list[NonNegativeInt] | None] | None = None
469
+ """One prompt token ID list per response, used to seed detokenization.
470
+ See `DerenderChatRequest.prompt_token_ids`.
471
+
472
+ If provided, len(prompt_token_ids) must equal len(generate_responses).
473
+ A `None` entry falls back to `generate_responses[i].prompt_token_ids`.
474
+ """
475
+
476
completion_request: CompletionRequest | None = None
477
"""The original (post-adjust_request) CompletionRequest from /render.
478
@@ -472,6 +490,13 @@ class DerenderCompletionRequest(BaseModel):
490
f"prompt_tokens length ({len(self.prompt_tokens)}) must equal "
491
f"generate_responses length ({len(self.generate_responses)})"
492
)
493
+ if self.prompt_token_ids is not None and len(self.prompt_token_ids) != len(
494
+ self.generate_responses
495
+ ):
496
+ raise ValueError(
497
+ f"prompt_token_ids length ({len(self.prompt_token_ids)}) must "
498
+ f"equal generate_responses length ({len(self.generate_responses)})"
499
+ )
500
return self
501
502
@@ -627,7 +652,7 @@ class DerenderChatStreamRequest(BaseModel):
652
prompt_tokens: int | None = None
653
"""Prompt token count for usage. Forwarded from the render step."""
654
630
- prompt_token_ids: list[int] | None = None
655
+ prompt_token_ids: list[NonNegativeInt] | None = None
656
"""Prompt token IDs. Required by the parser path's `parse_delta` to
657
settle its initial reasoning state (e.g. chat templates that pre-open
658
``<think>``). `prompt_tokens` is a usage count and cannot serve this
@@ -636,8 +661,15 @@ class DerenderChatStreamRequest(BaseModel):
661
Rejected with a 400 (by `ServingDerender`) when a tool or reasoning
662
parser is configured and this is omitted. Without it, `parse_delta`
663
cannot tell whether the prompt left reasoning open and would silently
639
- misclassify reasoning content as plain content. Unused on the plain
640
- detokenization path.
664
+ misclassify reasoning content as plain content. On all paths it also
665
+ seeds detokenization on the first chunk, falling back to
666
+ `generate_chunk.prompt_token_ids` when omitted (see
667
+ `DerenderChatRequest.prompt_token_ids`).
668
+
669
+ With a parser configured, send the full list. Parsers look back to the
670
+ last reasoning marker which can be anywhere in the prompt, so a suffix
671
+ can flip the initial reasoning state. Without a parser, a suffix is
672
+ enough.
673
"""
674
675
chat_request: ChatCompletionRequest | None = None
@@ -666,6 +698,12 @@ class DerenderCompletionStreamRequest(BaseModel):
698
prompt_tokens: int | None = None
699
"""Prompt token count for usage."""
700
701
+ prompt_token_ids: list[NonNegativeInt] | None = None
702
+ """Prompt token IDs, used on the first chunk to seed detokenization.
703
+ Falls back to `generate_chunk.prompt_token_ids`. See
704
+ `DerenderChatRequest.prompt_token_ids`.
705
+ """
706
+
707
completion_request: CompletionRequest | None = None
708
"""The original (post adjust_request) CompletionRequest from /render."""
709
# --8<-- [end:derender-completion-stream-request]
vllm/renderers/online_derenderer.py
+191
-56
@@ -40,9 +40,14 @@ from vllm.logger import init_logger
40
from vllm.parser import Parser, ParserManager
41
from vllm.renderers import BaseRenderer
42
from vllm.tokenizers import TokenizerLike
43
-from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
43
+from vllm.tokenizers.detokenizer_utils import (
44
+ convert_prompt_ids_to_tokens,
45
+ detokenize_incrementally,
46
+ get_leading_space_marker,
47
+)
48
from vllm.utils import random_uuid
49
from vllm.utils.async_utils import make_async
50
+from vllm.v1.engine.detokenizer import uses_fast_detokenizer
51
52
logger = init_logger(__name__)
53
@@ -115,17 +120,37 @@ class OnlineDerenderer:
120
self,
121
generate_response: GenerateResponse,
122
chat_request: ChatCompletionRequest | None = None,
123
+ prompt_token_ids: list[int] | None = None,
124
) -> list[ChatCompletionResponseChoice]:
119
- return await self._derender_chat_async(generate_response, chat_request)
125
+ return await self._derender_chat_async(
126
+ generate_response, chat_request, prompt_token_ids
127
+ )
128
129
def _derender_chat(
130
self,
131
generate_response: GenerateResponse,
132
chat_request: ChatCompletionRequest | None = None,
133
+ prompt_token_ids: list[int] | None = None,
134
) -> list[ChatCompletionResponseChoice]:
135
tokenizer = self.renderer.get_tokenizer()
136
choices: list[ChatCompletionResponseChoice] = []
137
138
+ has_parser = self.parser is not None and chat_request is not None
139
+ skip_special, spaces_between = _decode_params(
140
+ tokenizer, chat_request, preserve_special=has_parser
141
+ )
142
+ seed_ids = (
143
+ prompt_token_ids
144
+ if prompt_token_ids is not None
145
+ else generate_response.prompt_token_ids
146
+ )
147
+ # Choices continue the same prompt, so they share one seeded window.
148
+ seed_state = _seed_stream_state(
149
+ tokenizer,
150
+ seed_ids,
151
+ skip_special_tokens=skip_special,
152
+ )
153
+
154
for choice in generate_response.choices:
155
if not choice.token_ids:
156
raise ValueError(f"choice {choice.index} has empty or null token_ids")
@@ -136,12 +161,17 @@ class OnlineDerenderer:
161
else None
162
)
163
139
- if self.parser is not None and chat_request is not None:
164
+ if has_parser:
165
+ assert self.parser is not None and chat_request is not None
166
# Parser path: decode with special tokens preserved
167
# so the parser can see markers like </think>,
168
# <tool_call>, or Harmony channel tokens.
143
- decoded_text = tokenizer.decode(
144
- choice.token_ids, skip_special_tokens=False
169
+ decoded_text, _ = self._detokenize_delta(
170
+ tokenizer,
171
+ choice.token_ids,
172
+ seed_state,
173
+ skip_special_tokens=skip_special,
174
+ spaces_between_special_tokens=spaces_between,
175
)
176
177
chat_template_kwargs: dict[str, Any] = {}
@@ -201,13 +231,12 @@ class OnlineDerenderer:
231
else:
232
# No parser: plain detokenization honouring the request's
233
# skip_special_tokens (default True when no request was given).
204
- skip_special = (
205
- chat_request.skip_special_tokens
206
- if chat_request is not None
207
- else True
208
- )
209
- decoded_text = tokenizer.decode(
210
- choice.token_ids, skip_special_tokens=skip_special
234
+ decoded_text, _ = self._detokenize_delta(
235
+ tokenizer,
236
+ choice.token_ids,
237
+ seed_state,
238
+ skip_special_tokens=skip_special,
239
+ spaces_between_special_tokens=spaces_between,
240
)
241
message = ChatMessage(role="assistant", content=decoded_text)
242
@@ -239,7 +268,7 @@ class OnlineDerenderer:
268
(tracked by ``read_offset``). The delta tokens are fed straight onto it.
269
270
The window is bounded. ``detokenize_incrementally`` never reads before
242
- ``prefix_offset``, so after processing we trim ``prev_tokens`` to that
271
+ ``prefix_offset``, so after each token we trim ``prev_tokens`` to that
272
tail and rebase the offsets to it. State transport therefore stays
273
O(window) per chunk instead of re-sending the full token history.
274
@@ -273,18 +302,20 @@ class OnlineDerenderer:
302
skip_special_tokens=skip_special_tokens,
303
spaces_between_special_tokens=spaces_between_special_tokens,
304
)
276
- prev_tokens = prev_tokens + new_toks
305
+ # Trim to the tail still readable by detokenize_incrementally
306
+ # (everything before prefix_offset is dead) and rebase the
307
+ # offsets, so the window stays bounded within a long batch
308
+ # decode as well as across chunks.
309
+ prev_tokens = (prev_tokens + new_toks)[prefix_offset:]
310
+ read_offset -= prefix_offset
311
+ prefix_offset = 0
312
text_parts.append(text)
313
279
- # Trim to the tail still readable by detokenize_incrementally
280
- # (everything before prefix_offset is dead) and rebase the offsets so
281
- # the carried window stays bounded regardless of generation length.
282
- trimmed = prev_tokens[prefix_offset:]
314
updated_state = state.model_copy(
315
update={
285
- "prev_tokens": trimmed,
286
- "prefix_offset": 0,
287
- "read_offset": read_offset - prefix_offset,
316
+ "prev_tokens": prev_tokens,
317
+ "prefix_offset": prefix_offset,
318
+ "read_offset": read_offset,
319
}
320
)
321
return "".join(text_parts), updated_state
@@ -315,20 +346,16 @@ class OnlineDerenderer:
346
(validated by the caller — see `ServingDerender`) because
347
plain detokenization would leak raw parser markup into `content`.
348
prompt_tokens: Prompt token count for the usage chunk.
318
- prompt_token_ids: Prompt token IDs. Parser path only. Lets
319
- `parse_delta` settle its initial reasoning state. Required
320
- when a reasoning or tool parser is configured (validated by
321
- the caller — see ``ServingDerender``). Without it reasoning
322
- left open by the prompt would be misclassified as content.
349
+ prompt_token_ids: Prompt token IDs. Required on the parser path
350
+ (validated by the caller) and optional otherwise, falling
351
+ back to ``generate_chunk.prompt_token_ids``. See
352
+ `DerenderChatStreamRequest.prompt_token_ids`.
353
354
Returns:
355
(chunk, updated_state) — the derendered SSE chunk and the state
356
the client must pass to the next call.
357
358
"""
329
- if state is None:
330
- state = DerenderStreamState()
331
-
359
# A single DerenderStreamState is threaded through every choice in
360
# this chunk. Correct only when there is at most one choice per SSE
361
# event (n=1, one call per index), as the streaming derender
@@ -353,23 +380,34 @@ class OnlineDerenderer:
380
parser_cls,
381
model,
382
generate_chunk,
356
- state,
383
+ state if state is not None else DerenderStreamState(),
384
chat_request,
385
prompt_tokens,
386
prompt_token_ids,
387
)
388
389
tokenizer = self.renderer.get_tokenizer()
363
- skip_special = (
364
- chat_request.skip_special_tokens if chat_request is not None else True
365
- )
390
+ skip_special, spaces_between = _decode_params(tokenizer, chat_request)
391
+ # Seed on the first chunk only. A carried state already has the prompt.
392
+ if state is None:
393
+ state = _seed_stream_state(
394
+ tokenizer,
395
+ prompt_token_ids
396
+ if prompt_token_ids is not None
397
+ else generate_chunk.prompt_token_ids,
398
+ skip_special_tokens=skip_special,
399
+ )
400
stream_choices: list[ChatCompletionResponseStreamChoice] = []
401
updated_state = state
402
403
for choice in generate_chunk.choices:
404
delta_tids = choice.token_ids or []
405
new_text, updated_state = await self._detokenize_delta_async(
372
- tokenizer, delta_tids, updated_state, skip_special_tokens=skip_special
406
+ tokenizer,
407
+ delta_tids,
408
+ updated_state,
409
+ skip_special_tokens=skip_special,
410
+ spaces_between_special_tokens=spaces_between,
411
)
412
413
# NOTE: parser-configured servers dispatch to
@@ -454,7 +492,8 @@ class OnlineDerenderer:
492
`state.output_chunk_lens`, so the rebuilt parser state matches
493
standard serving, which calls `parse_delta` once per engine step.
494
Text comes from a fresh incremental detokenizer with special tokens
457
- preserved (``skip_special_tokens=False``).
495
+ preserved (``skip_special_tokens=False``), seeded from the prompt
496
+ tail on every call since replay starts from scratch.
497
"""
498
tokenizer = self.renderer.get_tokenizer()
499
@@ -480,7 +519,12 @@ class OnlineDerenderer:
519
# across both the replay and current chunk phases (via `nonlocal`)
520
# so multi-byte characters split across that boundary still decode
521
# correctly. Discarded once the call returns.
483
- detok_state = DerenderStreamState()
522
+ skip_special, spaces_between = _decode_params(
523
+ tokenizer, chat_request, preserve_special=True
524
+ )
525
+ detok_state = _seed_stream_state(
526
+ tokenizer, prompt_token_ids, skip_special_tokens=skip_special
527
+ )
528
529
def _replay(token_ids: list[int], chunk_lens: list[int]) -> None:
530
"""Replay prior chunks through `parse_delta` to rebuild parser
@@ -493,7 +537,11 @@ class OnlineDerenderer:
537
chunk = token_ids[start : start + chunk_len]
538
start += chunk_len
539
text, detok_state = self._detokenize_delta(
496
- tokenizer, chunk, detok_state, skip_special_tokens=False
540
+ tokenizer,
541
+ chunk,
542
+ detok_state,
543
+ skip_special_tokens=skip_special,
544
+ spaces_between_special_tokens=spaces_between,
545
)
546
parser.parse_delta(
547
text,
@@ -530,7 +578,11 @@ class OnlineDerenderer:
578
# granularity), not one per token. See the granularity note
579
# in this method's docstring.
580
text, detok_state = self._detokenize_delta(
533
- tokenizer, delta_tids, detok_state, skip_special_tokens=False
581
+ tokenizer,
582
+ delta_tids,
583
+ detok_state,
584
+ skip_special_tokens=skip_special,
585
+ spaces_between_special_tokens=spaces_between,
586
)
587
delta_message = parser.parse_delta(
588
text,
@@ -633,9 +685,10 @@ class OnlineDerenderer:
685
generate_responses: list[GenerateResponse],
686
prompt_tokens: list[int] | None = None,
687
completion_request: CompletionRequest | None = None,
688
+ prompt_token_ids: list[list[int] | None] | None = None,
689
) -> tuple[list[CompletionResponseChoice], int, int]:
690
return await self._derender_completion_async(
638
- generate_responses, prompt_tokens, completion_request
691
+ generate_responses, prompt_tokens, completion_request, prompt_token_ids
692
)
693
694
def _derender_completion(
@@ -643,24 +696,34 @@ class OnlineDerenderer:
696
generate_responses: list[GenerateResponse],
697
prompt_tokens: list[int] | None = None,
698
completion_request: CompletionRequest | None = None,
699
+ prompt_token_ids: list[list[int] | None] | None = None,
700
) -> tuple[list[CompletionResponseChoice], int, int]:
701
n = len(generate_responses)
702
prompt_tokens_list: list[int] = (
703
prompt_tokens if prompt_tokens is not None else [0] * n
704
)
651
-
652
- skip_special = (
653
- completion_request.skip_special_tokens
654
- if completion_request is not None
655
- else True
705
+ prompt_token_ids_list: list[list[int] | None] = (
706
+ prompt_token_ids if prompt_token_ids is not None else [None] * n
707
)
708
+
709
tokenizer = self.renderer.get_tokenizer()
710
+ skip_special, spaces_between = _decode_params(tokenizer, completion_request)
711
choices: list[CompletionResponseChoice] = []
712
total_prompt_tokens = 0
713
total_completion_tokens = 0
714
index = 0
715
663
- for gen, pt in zip(generate_responses, prompt_tokens_list):
716
+ for gen, pt, seed_ids_override in zip(
717
+ generate_responses, prompt_tokens_list, prompt_token_ids_list
718
+ ):
719
+ seed_ids = (
720
+ seed_ids_override
721
+ if seed_ids_override is not None
722
+ else gen.prompt_token_ids
723
+ )
724
+ seed_state = _seed_stream_state(
725
+ tokenizer, seed_ids, skip_special_tokens=skip_special
726
+ )
727
for choice in gen.choices:
728
if not choice.token_ids:
729
raise ValueError(
@@ -668,8 +731,12 @@ class OnlineDerenderer:
731
"has empty or null token_ids"
732
)
733
671
- decoded_text = tokenizer.decode(
672
- choice.token_ids, skip_special_tokens=skip_special
734
+ decoded_text, _ = self._detokenize_delta(
735
+ tokenizer,
736
+ choice.token_ids,
737
+ seed_state,
738
+ skip_special_tokens=skip_special,
739
+ spaces_between_special_tokens=spaces_between,
740
)
741
completion_logprobs = None
742
if choice.logprobs is not None:
@@ -698,6 +765,7 @@ class OnlineDerenderer:
765
state: DerenderStreamState | None = None,
766
prompt_tokens: int | None = None,
767
completion_request: CompletionRequest | None = None,
768
+ prompt_token_ids: list[int] | None = None,
769
) -> tuple[CompletionStreamResponse, DerenderStreamState]:
770
"""Process one GenerateStreamResponse chunk for streaming completions.
771
@@ -716,14 +784,13 @@ class OnlineDerenderer:
784
prompt_tokens: Prompt token count for usage (from the render step).
785
completion_request: Original CompletionRequest from ``/render``;
786
supplies ``skip_special_tokens``.
787
+ prompt_token_ids: Seeds the first chunk's decode. Falls back to
788
+ ``generate_chunk.prompt_token_ids``.
789
790
Returns:
791
(chunk, updated_state) — the derendered chunk and updated state.
792
793
"""
724
- if state is None:
725
- state = DerenderStreamState()
726
-
794
# See the equivalent check in derender_chat_stream: a single
795
# DerenderStreamState is threaded through every choice in this
796
# chunk, so more than one choice per chunk would corrupt the
@@ -734,18 +801,27 @@ class OnlineDerenderer:
801
)
802
803
tokenizer = self.renderer.get_tokenizer()
737
- skip_special = (
738
- completion_request.skip_special_tokens
739
- if completion_request is not None
740
- else True
741
- )
804
+ skip_special, spaces_between = _decode_params(tokenizer, completion_request)
805
+ # Seed on the first chunk only. A carried state already has the prompt.
806
+ if state is None:
807
+ state = _seed_stream_state(
808
+ tokenizer,
809
+ prompt_token_ids
810
+ if prompt_token_ids is not None
811
+ else generate_chunk.prompt_token_ids,
812
+ skip_special_tokens=skip_special,
813
+ )
814
stream_choices: list[CompletionResponseStreamChoice] = []
815
updated_state = state
816
817
for choice in generate_chunk.choices:
818
delta_tids = choice.token_ids or []
819
new_text, updated_state = await self._detokenize_delta_async(
748
- tokenizer, delta_tids, updated_state, skip_special_tokens=skip_special
820
+ tokenizer,
821
+ delta_tids,
822
+ updated_state,
823
+ skip_special_tokens=skip_special,
824
+ spaces_between_special_tokens=spaces_between,
825
)
826
827
completion_logprobs = None
@@ -810,6 +886,65 @@ def _logprob_context_tail(
886
return (list(context_token_ids) + list(delta_token_ids))[-_LOGPROB_CONTEXT_WINDOW:]
887
888
889
+def _decode_params(
890
+ tokenizer: TokenizerLike,
891
+ request: ChatCompletionRequest | CompletionRequest | None,
892
+ preserve_special: bool = False,
893
+) -> tuple[bool, bool]:
894
+ """Derive `(skip_special_tokens, spaces_between_special_tokens)` the way
895
+ the engine does (`IncrementalDetokenizer`).
896
+
897
+ Args:
898
+ tokenizer: Picks the engine's fast or slow detokenizer rules.
899
+ request: The original request, if the caller supplied one.
900
+ preserve_special: Keep special tokens so a parser can see markers.
901
+ The serving side does this via `adjust_request`.
902
+
903
+ """
904
+ skip_special = (
905
+ False
906
+ if preserve_special
907
+ else (request.skip_special_tokens if request is not None else True)
908
+ )
909
+ spaces_between = (
910
+ request.spaces_between_special_tokens if request is not None else True
911
+ )
912
+ # Only the fast detokenizer forces spaces on when skipping special tokens.
913
+ if skip_special and uses_fast_detokenizer(tokenizer):
914
+ spaces_between = True
915
+ return skip_special, spaces_between
916
+
917
+
918
+def _seed_stream_state(
919
+ tokenizer: TokenizerLike,
920
+ prompt_token_ids: list[int] | None,
921
+ skip_special_tokens: bool,
922
+) -> DerenderStreamState:
923
+ """Build the initial decode state from the prompt tail, the same way the
924
+ engine primes its incremental detokenizer. Without it, Metaspace
925
+ tokenizers drop the first output token's leading space.
926
+
927
+ Returns an empty state when `prompt_token_ids` is empty or omitted.
928
+ """
929
+ if not prompt_token_ids:
930
+ if prompt_token_ids is None and get_leading_space_marker(tokenizer) is not None:
931
+ logger.warning_once(
932
+ "derender got no prompt_token_ids, so the first output token "
933
+ "may lose its leading space compared to the coupled endpoint. "
934
+ "Pass prompt_token_ids from the render step to avoid this."
935
+ )
936
+ return DerenderStreamState()
937
+
938
+ prev_tokens, prefix_offset, read_offset = convert_prompt_ids_to_tokens(
939
+ tokenizer, prompt_token_ids, skip_special_tokens=skip_special_tokens
940
+ )
941
+ return DerenderStreamState(
942
+ prev_tokens=prev_tokens,
943
+ prefix_offset=prefix_offset,
944
+ read_offset=read_offset,
945
+ )
946
+
947
+
948
def _parse_token_id_placeholder(token: str) -> int | None:
949
"""Extract token ID from a 'token_id:N' placeholder string."""
950
if not token.startswith("token_id:"):
vllm/tokenizers/detokenizer_utils.py
+2
-2
@@ -63,7 +63,7 @@ _CACHED_MARKER_KEY = "_vllm_space_marker_cache"
63
_NOT_CACHED = "__not_computed__"
64
65
66
-def _get_leading_space_marker(tokenizer: TokenizerLike) -> str | None:
66
+def get_leading_space_marker(tokenizer: TokenizerLike) -> str | None:
67
"""Read the space marker from the tokenizer's pre_tokenizer config.
68
69
Only Metaspace pre_tokenizers (used by SentencePiece-based models like
@@ -160,7 +160,7 @@ def convert_ids_list_to_tokens(
160
"""
161
if not token_ids:
162
return []
163
- marker = _get_leading_space_marker(tokenizer)
163
+ marker = get_leading_space_marker(tokenizer)
164
if marker is None:
165
return [tokenizer.decode([tid]) or "" for tid in token_ids]
166
raw_tokens = tokenizer.convert_ids_to_tokens(token_ids)
vllm/v1/engine/detokenizer.py
+6
-7
@@ -3,9 +3,7 @@
3
import sys
4
from abc import ABC, abstractmethod
5
6
-import tokenizers
6
import tokenizers.decoders
8
-from packaging import version
7
from tokenizers import Tokenizer
8
from transformers import TokenizersBackend
9
@@ -20,14 +18,15 @@ from vllm.v1.engine import EngineCoreRequest
18
19
logger = init_logger(__name__)
20
23
-# Only tokenizers >= 0.22.0 supports DecodeStream with native prefill
24
-# (ids parameter) used for FastIncrementalDetokenizer.
25
-USE_FAST_DETOKENIZER = version.parse(tokenizers.__version__) >= version.parse("0.22.0")
26
-
21
# Error string from https://github.com/huggingface/tokenizers/blob/909fdde2a4ffedd9295206f705eb612be2a91b12/tokenizers/src/tokenizer/mod.rs#L1042
22
INVALID_PREFIX_ERR_MSG = "Invalid prefix encountered"
23
24
25
+def uses_fast_detokenizer(tokenizer: TokenizerLike) -> bool:
26
+ """Whether the engine detokenizes with `FastIncrementalDetokenizer`."""
27
+ return isinstance(tokenizer, TokenizersBackend)
28
+
29
+
30
class IncrementalDetokenizer:
31
def __init__(self):
32
self.token_ids: list[int] = []
@@ -58,7 +57,7 @@ class IncrementalDetokenizer:
57
# No tokenizer => skipping detokenization.
58
return IncrementalDetokenizer()
59
61
- if USE_FAST_DETOKENIZER and isinstance(tokenizer, TokenizersBackend):
60
+ if uses_fast_detokenizer(tokenizer):
61
# Fast tokenizer => use tokenizers library DecodeStream.
62
return FastIncrementalDetokenizer(tokenizer, request)
63