[Frontend] Add detokenization streaming derender for disaggregated serving (#47301)
Signed-off-by: Martin Hickey <martin.hickey@ie.ibm.com>
Martin Hickey committed
Jul 30, 2026 at 02:01 UTC
0a31372e5fe7cc8bbaf96df0a3e88db9040cf8d5
6 files changed
+1377
-32
tests/entrypoints/scale_out/derender/test_derender_stream.py
new
+765
@@ -0,0 +1,765 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Unit tests for streaming derender.
5
+
6
+Tests are split into two layers:
7
+
8
+1. Unit tests (no server): covers ``_detokenize_delta`` correctness
9
+ (chunked == one-shot) and ``derender_completion_stream`` /
10
+ ``derender_chat_stream`` logic via a real tokenizer on a tiny model.
11
+
12
+2. Integration tests (require a running render server): covers the full
13
+ HTTP round-trip through the streaming endpoint. Marked with
14
+ ``@pytest.mark.asyncio`` and gated by the ``server`` / ``client``
15
+ fixtures from the sibling ``test_derender.py``.
16
+"""
17
+
18
+import pytest
19
+import pytest_asyncio
20
+
21
+from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
22
+ DerenderStreamState,
23
+ GenerateResponseStreamChoice,
24
+ GenerateStreamResponse,
25
+)
26
+
27
+MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
28
+
29
+
30
+# ---------------------------------------------------------------------------
31
+# Helpers shared across tests
32
+# ---------------------------------------------------------------------------
33
+
34
+
35
+def _make_stream_chunk(
36
+ token_ids: list[int],
37
+ index: int = 0,
38
+ finish_reason: str | None = None,
39
+ request_id: str = "test-req",
40
+ usage: dict | None = None,
41
+) -> GenerateStreamResponse:
42
+ """Build a GenerateStreamResponse SSE chunk."""
43
+ from vllm.entrypoints.openai.engine.protocol import UsageInfo
44
+
45
+ return GenerateStreamResponse(
46
+ request_id=request_id,
47
+ choices=[
48
+ GenerateResponseStreamChoice(
49
+ index=index,
50
+ token_ids=token_ids,
51
+ finish_reason=finish_reason,
52
+ )
53
+ ],
54
+ usage=UsageInfo(**usage) if usage else None,
55
+ )
56
+
57
+
58
+def _make_usage_chunk(
59
+ completion_tokens: int,
60
+ prompt_tokens: int = 0,
61
+ request_id: str = "test-req",
62
+) -> GenerateStreamResponse:
63
+ """Build a usage only final SSE chunk (empty choices)."""
64
+ from vllm.entrypoints.openai.engine.protocol import UsageInfo
65
+
66
+ return GenerateStreamResponse(
67
+ request_id=request_id,
68
+ choices=[],
69
+ usage=UsageInfo(
70
+ prompt_tokens=prompt_tokens,
71
+ completion_tokens=completion_tokens,
72
+ total_tokens=prompt_tokens + completion_tokens,
73
+ ),
74
+ )
75
+
76
+
77
+# ---------------------------------------------------------------------------
78
+# Unit tests — no running server
79
+# ---------------------------------------------------------------------------
80
+
81
+
82
+@pytest.fixture(scope="module")
83
+def tokenizer():
84
+ """Load the tiny tokenizer used across unit tests."""
85
+ from vllm.tokenizers import get_tokenizer
86
+
87
+ return get_tokenizer(MODEL_NAME)
88
+
89
+
90
+@pytest.fixture(scope="module")
91
+def derenderer(tokenizer):
92
+ """Construct a minimal OnlineDerenderer backed by a stub renderer."""
93
+ from unittest.mock import MagicMock
94
+
95
+ from vllm.renderers.online_derenderer import OnlineDerenderer
96
+
97
+ renderer = MagicMock()
98
+ renderer.get_tokenizer.return_value = tokenizer
99
+
100
+ model_config = MagicMock()
101
+ model_config.hf_config.model_type = "llama"
102
+ model_config.model = MODEL_NAME
103
+
104
+ return OnlineDerenderer(
105
+ model_config=model_config,
106
+ renderer=renderer,
107
+ request_logger=None,
108
+ chat_template=None,
109
+ chat_template_content_format="string",
110
+ trust_request_chat_template=False,
111
+ enable_auto_tools=False,
112
+ tool_parser=None,
113
+ reasoning_parser=None,
114
+ )
115
+
116
+
117
+class TestDetokenizeDelta:
118
+ """_detokenize_delta: chunked decode must equal one shot decode."""
119
+
120
+ def _one_shot(self, tokenizer, token_ids: list[int]) -> str:
121
+ return tokenizer.decode(token_ids, skip_special_tokens=True)
122
+
123
+ def _chunked(self, derenderer, tokenizer, chunks: list[list[int]]) -> str:
124
+ state = DerenderStreamState()
125
+ parts: list[str] = []
126
+ for delta in chunks:
127
+ text, state = derenderer._detokenize_delta(
128
+ tokenizer, delta, state, skip_special_tokens=True
129
+ )
130
+ parts.append(text)
131
+ return "".join(parts)
132
+
133
+ def test_single_chunk(self, derenderer, tokenizer):
134
+ """All tokens in one chunk == one shot decode."""
135
+ token_ids = tokenizer.encode("Hello world")[:8]
136
+ assert self._chunked(derenderer, tokenizer, [token_ids]) == self._one_shot(
137
+ tokenizer, token_ids
138
+ )
139
+
140
+ def test_two_equal_chunks(self, derenderer, tokenizer):
141
+ """Split in half and reassemble == one shot."""
142
+ token_ids = tokenizer.encode("Hello world from streaming derender")[:12]
143
+ mid = len(token_ids) // 2
144
+ chunks = [token_ids[:mid], token_ids[mid:]]
145
+ assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
146
+ tokenizer, token_ids
147
+ )
148
+
149
+ def test_single_token_per_chunk(self, derenderer, tokenizer):
150
+ """One token per chunk (most granular streaming) == one shot."""
151
+ token_ids = tokenizer.encode("incremental detokenization test")[:10]
152
+ chunks = [[t] for t in token_ids]
153
+ assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
154
+ tokenizer, token_ids
155
+ )
156
+
157
+ def test_empty_delta_passthrough(self, derenderer, tokenizer):
158
+ """An empty delta (usage only chunk) emits empty string and preserves state."""
159
+ token_ids = tokenizer.encode("Hello")[:4]
160
+ _, state = derenderer._detokenize_delta(
161
+ tokenizer, token_ids, DerenderStreamState(), skip_special_tokens=True
162
+ )
163
+ text, new_state = derenderer._detokenize_delta(
164
+ tokenizer, [], state, skip_special_tokens=True
165
+ )
166
+ assert text == ""
167
+ assert new_state.prev_tokens == state.prev_tokens
168
+ assert new_state.prefix_offset == state.prefix_offset
169
+ assert new_state.read_offset == state.read_offset
170
+
171
+ def test_multibyte_char_split_across_chunks(self, derenderer, tokenizer):
172
+ """A CJK/emoji char straddling chunk boundaries == one shot.
173
+
174
+ Regression test for held back trailing incomplete UTF-8 byte
175
+ sequences being dropped when the rebuild window marks them as
176
+ already read (see #46159).
177
+ """
178
+ token_ids = tokenizer.encode("Hello ✅ world 日本語")[:16]
179
+ chunks = [[t] for t in token_ids]
180
+ assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
181
+ tokenizer, token_ids
182
+ )
183
+
184
+ def test_state_carries_across_calls(self, derenderer, tokenizer):
185
+ """Decode state threads across calls. Text still matches one shot."""
186
+ t1 = tokenizer.encode("Hello")[:2]
187
+ t2 = tokenizer.encode(" world")[:2]
188
+ state = DerenderStreamState()
189
+ text1, state = derenderer._detokenize_delta(tokenizer, t1, state)
190
+ text2, state = derenderer._detokenize_delta(tokenizer, t2, state)
191
+ assert text1 + text2 == self._one_shot(tokenizer, t1 + t2)
192
+ # Offsets are rebased to the carried tail each chunk
193
+ assert state.prefix_offset == 0
194
+
195
+ def test_state_window_stays_bounded(self, derenderer, tokenizer):
196
+ """prev_tokens must not grow with the number of chunks (bounded transport).
197
+
198
+ Guards that the carried decode window is a small constant
199
+ tail, so cumulative ``stream_state`` transport is O(n) and not O(n^2).
200
+ """
201
+ token_ids = tokenizer.encode(
202
+ "a reasonably long ascii stream of tokens used to exercise the "
203
+ "window bound across many single token chunks so the carried "
204
+ "state cannot grow linearly with the generation length"
205
+ )
206
+ assert len(token_ids) > 32
207
+ state = DerenderStreamState()
208
+ max_window = 0
209
+ for tok in token_ids:
210
+ _, state = derenderer._detokenize_delta(tokenizer, [tok], state)
211
+ max_window = max(max_window, len(state.prev_tokens))
212
+ # Bounded by a small constant independent of len(token_ids)
213
+ assert max_window <= 32
214
+
215
+ def test_n_independent_streams_same_result(self, derenderer, tokenizer):
216
+ """N parallel streams with the same token sequence give the same text."""
217
+ token_ids = tokenizer.encode("parallel streams")[:8]
218
+ mid = len(token_ids) // 2
219
+
220
+ results = []
221
+ for _ in range(3):
222
+ state = DerenderStreamState()
223
+ text, state = derenderer._detokenize_delta(
224
+ tokenizer, token_ids[:mid], state
225
+ )
226
+ text2, _ = derenderer._detokenize_delta(tokenizer, token_ids[mid:], state)
227
+ results.append(text + text2)
228
+
229
+ assert len(set(results)) == 1, "All independent streams must produce same text"
230
+ assert results[0] == self._one_shot(tokenizer, token_ids)
231
+
232
+
233
+class TestDerenderCompletionStream:
234
+ """derender_completion_stream: streaming output parity with one shot."""
235
+
236
+ @pytest.mark.asyncio
237
+ async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
238
+ """Sum of streaming text chunks == one shot tokenizer.decode."""
239
+ token_ids = tokenizer.encode("streaming completion test")[:10]
240
+ mid = len(token_ids) // 2
241
+
242
+ state = DerenderStreamState()
243
+ chunk1, state = await derenderer.derender_completion_stream(
244
+ model=MODEL_NAME,
245
+ generate_chunk=_make_stream_chunk(token_ids[:mid]),
246
+ state=state,
247
+ )
248
+ chunk2, _ = await derenderer.derender_completion_stream(
249
+ model=MODEL_NAME,
250
+ generate_chunk=_make_stream_chunk(token_ids[mid:], finish_reason="stop"),
251
+ state=state,
252
+ )
253
+
254
+ streamed_text = chunk1.choices[0].text + chunk2.choices[0].text
255
+ one_shot = tokenizer.decode(token_ids, skip_special_tokens=True)
256
+ assert streamed_text == one_shot
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_usage_chunk_passthrough(self, derenderer, tokenizer):
260
+ """Usage only final chunk (empty choices) is passed through correctly."""
261
+ usage_chunk = _make_usage_chunk(completion_tokens=10, prompt_tokens=5)
262
+ chunk, state = await derenderer.derender_completion_stream(
263
+ model=MODEL_NAME,
264
+ generate_chunk=usage_chunk,
265
+ )
266
+ assert chunk.choices == []
267
+ assert chunk.usage is not None
268
+ assert chunk.usage.completion_tokens == 10
269
+ assert chunk.usage.prompt_tokens == 5
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_prompt_tokens_in_usage(self, derenderer, tokenizer):
273
+ """prompt_tokens is correctly forwarded into usage on a usage chunk."""
274
+ token_ids = tokenizer.encode("hello")[:3]
275
+ usage_chunk = _make_usage_chunk(
276
+ completion_tokens=len(token_ids), prompt_tokens=7
277
+ )
278
+ chunk, _ = await derenderer.derender_completion_stream(
279
+ model=MODEL_NAME,
280
+ generate_chunk=usage_chunk,
281
+ prompt_tokens=7,
282
+ )
283
+ assert chunk.usage is not None
284
+ assert chunk.usage.prompt_tokens == 7
285
+
286
+ @pytest.mark.asyncio
287
+ async def test_none_state_initialises_correctly(self, derenderer, tokenizer):
288
+ """Passing state=None (first call) initialises an empty DerenderStreamState."""
289
+ token_ids = tokenizer.encode("hello")[:4]
290
+ chunk, state = await derenderer.derender_completion_stream(
291
+ model=MODEL_NAME,
292
+ generate_chunk=_make_stream_chunk(token_ids),
293
+ state=None,
294
+ )
295
+ assert isinstance(state, DerenderStreamState)
296
+ assert chunk.choices[0].text == tokenizer.decode(
297
+ token_ids, skip_special_tokens=True
298
+ )
299
+
300
+ @pytest.mark.asyncio
301
+ async def test_skip_special_tokens_threaded(self, derenderer, tokenizer):
302
+ """completion_request.skip_special_tokens is honored (not hardcoded True)."""
303
+ from vllm.entrypoints.openai.completion.protocol import CompletionRequest
304
+
305
+ eos = tokenizer.eos_token_id
306
+ if eos is None:
307
+ pytest.skip("tokenizer has no eos token to exercise special stripping")
308
+ token_ids = tokenizer.encode("hi")[:2] + [eos]
309
+
310
+ async def _text(skip: bool) -> str:
311
+ req = CompletionRequest(
312
+ model=MODEL_NAME, prompt="x", skip_special_tokens=skip
313
+ )
314
+ chunk, _ = await derenderer.derender_completion_stream(
315
+ model=MODEL_NAME,
316
+ generate_chunk=_make_stream_chunk(token_ids),
317
+ completion_request=req,
318
+ )
319
+ return chunk.choices[0].text
320
+
321
+ # skip=False must retain the special token; skip=True must strip it.
322
+ assert await _text(False) != await _text(True)
323
+
324
+ @pytest.mark.asyncio
325
+ async def test_finish_reason_forwarded(self, derenderer, tokenizer):
326
+ """finish_reason from the generate chunk reaches the derendered choice."""
327
+ token_ids = tokenizer.encode("done")[:2]
328
+ chunk, _ = await derenderer.derender_completion_stream(
329
+ model=MODEL_NAME,
330
+ generate_chunk=_make_stream_chunk(token_ids, finish_reason="length"),
331
+ )
332
+ assert chunk.choices[0].finish_reason == "length"
333
+
334
+
335
+class TestDerenderChatStream:
336
+ """derender_chat_stream: plain detok branch (no parser)."""
337
+
338
+ @pytest.mark.asyncio
339
+ async def test_role_on_first_chunk_only(self, derenderer, tokenizer):
340
+ """role='assistant' appears in the first chunk, not subsequent ones."""
341
+ token_ids = tokenizer.encode("hello world")[:6]
342
+ mid = len(token_ids) // 2
343
+
344
+ state = DerenderStreamState()
345
+ chunk1, state = await derenderer.derender_chat_stream(
346
+ model=MODEL_NAME,
347
+ generate_chunk=_make_stream_chunk(token_ids[:mid]),
348
+ state=state,
349
+ )
350
+ chunk2, _ = await derenderer.derender_chat_stream(
351
+ model=MODEL_NAME,
352
+ generate_chunk=_make_stream_chunk(token_ids[mid:], finish_reason="stop"),
353
+ state=state,
354
+ )
355
+
356
+ assert chunk1.choices[0].delta.role == "assistant"
357
+ assert chunk2.choices[0].delta.role is None
358
+
359
+ @pytest.mark.asyncio
360
+ async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
361
+ """Sum of streaming content deltas == one shot decode."""
362
+ token_ids = tokenizer.encode("streaming chat derender text")[:10]
363
+ mid = len(token_ids) // 2
364
+
365
+ state = DerenderStreamState()
366
+ chunk1, state = await derenderer.derender_chat_stream(
367
+ model=MODEL_NAME,
368
+ generate_chunk=_make_stream_chunk(token_ids[:mid]),
369
+ state=state,
370
+ )
371
+ chunk2, _ = await derenderer.derender_chat_stream(
372
+ model=MODEL_NAME,
373
+ generate_chunk=_make_stream_chunk(token_ids[mid:]),
374
+ state=state,
375
+ )
376
+
377
+ streamed = (chunk1.choices[0].delta.content or "") + (
378
+ chunk2.choices[0].delta.content or ""
379
+ )
380
+ one_shot = tokenizer.decode(token_ids, skip_special_tokens=True)
381
+ assert streamed == one_shot
382
+
383
+ @pytest.mark.asyncio
384
+ @pytest.mark.parametrize("with_chat_request", [True, False])
385
+ async def test_parser_raises_not_implemented(self, tokenizer, with_chat_request):
386
+ """Stream chat with a parser active must fail closed (NotImplementedError).
387
+
388
+ Checks that a parser configured model must 501 even when ``chat_request`` is
389
+ omitted, otherwise reasoning/tool markup would leak into ``delta.content`
390
+ via the plain detok fallback.
391
+ """
392
+ from unittest.mock import MagicMock
393
+
394
+ from vllm.renderers.online_derenderer import OnlineDerenderer
395
+
396
+ renderer = MagicMock()
397
+ renderer.get_tokenizer.return_value = tokenizer
398
+
399
+ model_config = MagicMock()
400
+ model_config.hf_config.model_type = "llama"
401
+ model_config.model = MODEL_NAME
402
+
403
+ # Construct a derenderer WITH a tool/reasoning parser active.
404
+ # ParserManager.get_parser returns None when no parser is named, so
405
+ # we inject a mock parser class directly.
406
+ dr = OnlineDerenderer(
407
+ model_config=model_config,
408
+ renderer=renderer,
409
+ request_logger=None,
410
+ chat_template=None,
411
+ chat_template_content_format="string",
412
+ )
413
+ dr.parser = MagicMock() # simulate a parser being active
414
+
415
+ chat_request = MagicMock() if with_chat_request else None
416
+ token_ids = tokenizer.encode("hello")[:3]
417
+
418
+ with pytest.raises(NotImplementedError):
419
+ await dr.derender_chat_stream(
420
+ model=MODEL_NAME,
421
+ generate_chunk=_make_stream_chunk(token_ids),
422
+ state=None,
423
+ chat_request=chat_request,
424
+ )
425
+
426
+
427
+class TestDerenderStreamStateValidation:
428
+ """DerenderStreamState rejects malformed caller supplied offsets/lengths."""
429
+
430
+ def test_negative_prefix_offset_rejected(self):
431
+ from pydantic import ValidationError
432
+
433
+ with pytest.raises(ValidationError):
434
+ DerenderStreamState(prefix_offset=-1)
435
+
436
+ def test_negative_read_offset_rejected(self):
437
+ from pydantic import ValidationError
438
+
439
+ with pytest.raises(ValidationError):
440
+ DerenderStreamState(read_offset=-1)
441
+
442
+ def test_prev_tokens_over_cap_rejected(self):
443
+ from pydantic import ValidationError
444
+
445
+ with pytest.raises(ValidationError):
446
+ DerenderStreamState(prev_tokens=["a"] * 1025)
447
+
448
+ def test_prev_tokens_at_cap_accepted(self):
449
+ state = DerenderStreamState(prev_tokens=["a"] * 1024)
450
+ assert len(state.prev_tokens) == 1024
451
+
452
+
453
+class TestServingDerenderStreamErrorHandling:
454
+ """Malformed stream_state must surface as 400 and not an unhandled 500."""
455
+
456
+ def _make_serving(self, side_effect: Exception):
457
+ from unittest.mock import AsyncMock, MagicMock
458
+
459
+ from vllm.entrypoints.scale_out.derender.serving import ServingDerender
460
+
461
+ models = MagicMock()
462
+ models.is_base_model.return_value = True
463
+ models.model_config = MagicMock()
464
+
465
+ online_derenderer = MagicMock()
466
+ online_derenderer.derender_completion_stream = AsyncMock(
467
+ side_effect=side_effect
468
+ )
469
+ online_derenderer.derender_chat_stream = AsyncMock(side_effect=side_effect)
470
+
471
+ return ServingDerender(models=models, online_derenderer=online_derenderer)
472
+
473
+ @pytest.mark.asyncio
474
+ @pytest.mark.parametrize("exc", [KeyError("bad byte"), IndexError("oob")])
475
+ async def test_completion_stream_bad_state_returns_400(self, exc):
476
+ from vllm.entrypoints.openai.engine.protocol import ErrorResponse
477
+ from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
478
+ DerenderCompletionStreamRequest,
479
+ )
480
+
481
+ serving = self._make_serving(exc)
482
+ request = DerenderCompletionStreamRequest(
483
+ stream=True,
484
+ model=MODEL_NAME,
485
+ generate_chunk=_make_stream_chunk([1, 2]),
486
+ stream_state=DerenderStreamState(),
487
+ )
488
+ result = await serving.derender_completion_stream_response(request)
489
+ assert isinstance(result, ErrorResponse)
490
+ assert result.error.code == 400
491
+
492
+ @pytest.mark.asyncio
493
+ @pytest.mark.parametrize("exc", [KeyError("bad byte"), IndexError("oob")])
494
+ async def test_chat_stream_bad_state_returns_400(self, exc):
495
+ from vllm.entrypoints.openai.engine.protocol import ErrorResponse
496
+ from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
497
+ DerenderChatStreamRequest,
498
+ )
499
+
500
+ serving = self._make_serving(exc)
501
+ request = DerenderChatStreamRequest(
502
+ stream=True,
503
+ model=MODEL_NAME,
504
+ generate_chunk=_make_stream_chunk([1, 2]),
505
+ stream_state=DerenderStreamState(),
506
+ )
507
+ result = await serving.derender_chat_stream_response(request)
508
+ assert isinstance(result, ErrorResponse)
509
+ assert result.error.code == 400
510
+
511
+
512
+# ---------------------------------------------------------------------------
513
+# Integration tests — require a live render server
514
+# ---------------------------------------------------------------------------
515
+
516
+
517
+@pytest.fixture(scope="module")
518
+def server():
519
+ from tests.utils import RemoteLaunchRenderServer
520
+
521
+ with RemoteLaunchRenderServer(MODEL_NAME, []) as remote_server:
522
+ yield remote_server
523
+
524
+
525
+@pytest_asyncio.fixture
526
+async def client(server):
527
+ import httpx
528
+
529
+ async with httpx.AsyncClient(
530
+ base_url=server.url_for(""), timeout=30.0
531
+ ) as http_client:
532
+ yield http_client
533
+
534
+
535
+async def _render_chat(client) -> dict:
536
+ """Render a minimal chat request and return the GenerateRequest dict."""
537
+
538
+ resp = await client.post(
539
+ "/v1/chat/completions/render",
540
+ json={
541
+ "model": MODEL_NAME,
542
+ "messages": [{"role": "user", "content": "Hello"}],
543
+ },
544
+ )
545
+ assert resp.status_code == 200
546
+ return resp.json()
547
+
548
+
549
+@pytest.mark.asyncio
550
+async def test_streaming_completion_derender_roundtrip(client):
551
+ """Streaming completions derender: chunked text == non streaming text."""
552
+ gen_req = await _render_chat(client)
553
+ token_ids: list[int] = gen_req["token_ids"][:8]
554
+ mid = len(token_ids) // 2
555
+ chunk1_ids, chunk2_ids = token_ids[:mid], token_ids[mid:]
556
+
557
+ # Non streaming baseline.
558
+ non_stream_resp = await client.post(
559
+ "/v1/completions/derender",
560
+ json={
561
+ "model": MODEL_NAME,
562
+ "generate_responses": [
563
+ {
564
+ "request_id": "test-ns",
565
+ "choices": [
566
+ {
567
+ "index": 0,
568
+ "token_ids": token_ids,
569
+ "finish_reason": "stop",
570
+ }
571
+ ],
572
+ }
573
+ ],
574
+ },
575
+ )
576
+ assert non_stream_resp.status_code == 200
577
+ expected_text = non_stream_resp.json()["choices"][0]["text"]
578
+
579
+ # Streaming call 1.
580
+ r1 = await client.post(
581
+ "/v1/completions/derender",
582
+ json={
583
+ "stream": True,
584
+ "model": MODEL_NAME,
585
+ "generate_chunk": {
586
+ "request_id": "test-s",
587
+ "choices": [
588
+ {"index": 0, "token_ids": chunk1_ids, "finish_reason": None}
589
+ ],
590
+ },
591
+ "stream_state": None,
592
+ },
593
+ )
594
+ assert r1.status_code == 200
595
+ d1 = r1.json()
596
+ text1 = d1["chunk"]["choices"][0]["text"]
597
+ state1 = d1["stream_state"]
598
+
599
+ # Streaming call 2 (final chunk).
600
+ r2 = await client.post(
601
+ "/v1/completions/derender",
602
+ json={
603
+ "stream": True,
604
+ "model": MODEL_NAME,
605
+ "generate_chunk": {
606
+ "request_id": "test-s",
607
+ "choices": [
608
+ {"index": 0, "token_ids": chunk2_ids, "finish_reason": "stop"}
609
+ ],
610
+ },
611
+ "stream_state": state1,
612
+ },
613
+ )
614
+ assert r2.status_code == 200
615
+ text2 = r2.json()["chunk"]["choices"][0]["text"]
616
+
617
+ assert text1 + text2 == expected_text
618
+
619
+
620
+@pytest.mark.asyncio
621
+async def test_streaming_chat_derender_roundtrip(client):
622
+ """Streaming chat derender (plain detok): chunked text == non streaming text."""
623
+ gen_req = await _render_chat(client)
624
+ token_ids: list[int] = gen_req["token_ids"][:8]
625
+ mid = len(token_ids) // 2
626
+ chunk1_ids, chunk2_ids = token_ids[:mid], token_ids[mid:]
627
+
628
+ # Non streaming baseline.
629
+ ns = await client.post(
630
+ "/v1/chat/completions/derender",
631
+ json={
632
+ "model": MODEL_NAME,
633
+ "generate_response": {
634
+ "request_id": "test-ns",
635
+ "choices": [
636
+ {
637
+ "index": 0,
638
+ "token_ids": token_ids,
639
+ "finish_reason": "stop",
640
+ }
641
+ ],
642
+ },
643
+ },
644
+ )
645
+ assert ns.status_code == 200
646
+ expected_content = ns.json()["choices"][0]["message"]["content"]
647
+
648
+ # Streaming call 1.
649
+ r1 = await client.post(
650
+ "/v1/chat/completions/derender",
651
+ json={
652
+ "stream": True,
653
+ "model": MODEL_NAME,
654
+ "generate_chunk": {
655
+ "request_id": "test-s",
656
+ "choices": [
657
+ {"index": 0, "token_ids": chunk1_ids, "finish_reason": None}
658
+ ],
659
+ },
660
+ "stream_state": None,
661
+ },
662
+ )
663
+ assert r1.status_code == 200
664
+ d1 = r1.json()
665
+ text1 = d1["chunk"]["choices"][0]["delta"].get("content") or ""
666
+ state1 = d1["stream_state"]
667
+ # role=assistant on the first chunk
668
+ assert d1["chunk"]["choices"][0]["delta"].get("role") == "assistant"
669
+
670
+ # Streaming call 2.
671
+ r2 = await client.post(
672
+ "/v1/chat/completions/derender",
673
+ json={
674
+ "stream": True,
675
+ "model": MODEL_NAME,
676
+ "generate_chunk": {
677
+ "request_id": "test-s",
678
+ "choices": [
679
+ {"index": 0, "token_ids": chunk2_ids, "finish_reason": "stop"}
680
+ ],
681
+ },
682
+ "stream_state": state1,
683
+ },
684
+ )
685
+ assert r2.status_code == 200
686
+ d2 = r2.json()
687
+ text2 = d2["chunk"]["choices"][0]["delta"].get("content") or ""
688
+ # role must NOT be repeated on subsequent chunks
689
+ assert d2["chunk"]["choices"][0]["delta"].get("role") is None
690
+
691
+ assert text1 + text2 == expected_content
692
+
693
+
694
+@pytest.mark.asyncio
695
+async def test_streaming_derender_invalid_body_returns_400(client):
696
+ """Missing required field in streaming request returns 400."""
697
+ r = await client.post(
698
+ "/v1/completions/derender",
699
+ json={
700
+ "stream": True,
701
+ # missing required 'model' and 'generate_chunk'
702
+ },
703
+ )
704
+ assert r.status_code == 400
705
+
706
+
707
+@pytest.mark.asyncio
708
+async def test_streaming_derender_non_object_body_returns_400(client):
709
+ """A non object JSON body (e.g. a list) returns 400, not a 500."""
710
+ r = await client.post(
711
+ "/v1/completions/derender",
712
+ json=[1, 2, 3],
713
+ )
714
+ assert r.status_code == 400
715
+
716
+
717
+@pytest.mark.asyncio
718
+async def test_streaming_usage_chunk(client):
719
+ """Usage only final chunk is forwarded with correct token counts."""
720
+ gen_req = await _render_chat(client)
721
+ token_ids: list[int] = gen_req["token_ids"][:6]
722
+ state: dict = {}
723
+
724
+ # Send content chunk first.
725
+ r1 = await client.post(
726
+ "/v1/completions/derender",
727
+ json={
728
+ "stream": True,
729
+ "model": MODEL_NAME,
730
+ "generate_chunk": {
731
+ "request_id": "usage-test",
732
+ "choices": [
733
+ {"index": 0, "token_ids": token_ids, "finish_reason": "stop"}
734
+ ],
735
+ },
736
+ "stream_state": None,
737
+ },
738
+ )
739
+ assert r1.status_code == 200
740
+ state = r1.json()["stream_state"]
741
+
742
+ # Send usage only final chunk.
743
+ r2 = await client.post(
744
+ "/v1/completions/derender",
745
+ json={
746
+ "stream": True,
747
+ "model": MODEL_NAME,
748
+ "generate_chunk": {
749
+ "request_id": "usage-test",
750
+ "choices": [],
751
+ "usage": {
752
+ "prompt_tokens": 10,
753
+ "completion_tokens": len(token_ids),
754
+ "total_tokens": 10 + len(token_ids),
755
+ },
756
+ },
757
+ "stream_state": state,
758
+ "prompt_tokens": 10,
759
+ },
760
+ )
761
+ assert r2.status_code == 200
762
+ d2 = r2.json()
763
+ assert d2["chunk"]["choices"] == []
764
+ assert d2["chunk"]["usage"]["prompt_tokens"] == 10
765
+ assert d2["chunk"]["usage"]["completion_tokens"] == len(token_ids)
vllm/entrypoints/scale_out/derender/api_router.py
+64
-12
@@ -5,15 +5,17 @@ from http import HTTPStatus
5
from fastapi import APIRouter, Depends, Request
6
from fastapi.responses import JSONResponse
7
8
-from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionResponse
9
-from vllm.entrypoints.openai.completion.protocol import CompletionResponse
8
from vllm.entrypoints.openai.engine.protocol import ErrorResponse
9
from vllm.entrypoints.serve.utils.api_utils import validate_json_request
10
from vllm.logger import init_logger
11
12
from ..token_in_token_out.protocol import (
15
- DerenderChatRequest,
16
- DerenderCompletionRequest,
13
+ DerenderChatRequestUnion,
14
+ DerenderChatStreamRequest,
15
+ DerenderChatStreamResponse,
16
+ DerenderCompletionRequestUnion,
17
+ DerenderCompletionStreamRequest,
18
+ DerenderCompletionStreamResponse,
19
)
20
from .serving import ServingDerender
21
@@ -29,46 +31,96 @@ def derender(request: Request) -> ServingDerender | None:
31
@router.post(
32
"/v1/chat/completions/derender",
33
dependencies=[Depends(validate_json_request)],
32
- response_model=ChatCompletionResponse,
34
responses={
35
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
36
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
37
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
38
},
39
)
39
-async def derender_chat_completion(request: DerenderChatRequest, raw_request: Request):
40
+async def derender_chat_completion(
41
+ request: DerenderChatRequestUnion,
42
+ raw_request: Request,
43
+):
44
+ """Derender a generate response into a ChatCompletionResponse.
45
+
46
+ Accepts both non-streaming (``stream=false``, default) and streaming
47
+ (``stream=true``) request bodies on the same path; FastAPI validates and
48
+ routes on the ``stream`` discriminator.
49
+
50
+ Non-streaming: body is ``DerenderChatRequest`` (``generate_response`` with
51
+ the complete token list). Returns a ``ChatCompletionResponse``.
52
+
53
+ Streaming: body is ``DerenderChatStreamRequest`` (one ``generate_chunk``
54
+ delta + optional ``stream_state``). Returns a ``DerenderChatStreamResponse``
55
+ (``chunk`` + ``stream_state``). The client carries ``stream_state`` between
56
+ successive calls, one per SSE chunk from ``/inference/v1/generate``.
57
+ """
58
handler = derender(raw_request)
59
if handler is None:
60
raise NotImplementedError(
61
"The model does not support Chat Completions Derender API"
62
)
63
46
- result = await handler.derender_chat_response(request)
64
+ if isinstance(request, DerenderChatStreamRequest):
65
+ stream_result = await handler.derender_chat_stream_response(request)
66
+ if isinstance(stream_result, ErrorResponse):
67
+ return JSONResponse(
68
+ content=stream_result.model_dump(),
69
+ status_code=stream_result.error.code,
70
+ )
71
+ chunk, stream_state = stream_result
72
+ response = DerenderChatStreamResponse(chunk=chunk, stream_state=stream_state)
73
+ return JSONResponse(content=response.model_dump())
74
75
+ result = await handler.derender_chat_response(request)
76
if isinstance(result, ErrorResponse):
77
return JSONResponse(content=result.model_dump(), status_code=result.error.code)
50
-
78
return JSONResponse(content=result.model_dump())
79
80
81
@router.post(
82
"/v1/completions/derender",
83
dependencies=[Depends(validate_json_request)],
57
- response_model=CompletionResponse,
84
responses={
85
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
86
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
87
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
88
},
89
)
64
-async def derender_completion(request: DerenderCompletionRequest, raw_request: Request):
90
+async def derender_completion(
91
+ request: DerenderCompletionRequestUnion,
92
+ raw_request: Request,
93
+):
94
+ """Derender a generate response into a CompletionResponse.
95
+
96
+ Accepts both non-streaming (``stream=false``, default) and streaming
97
+ (``stream=true``) request bodies on the same path.
98
+
99
+ Non-streaming: body is ``DerenderCompletionRequest``. Returns a
100
+ ``CompletionResponse``.
101
+
102
+ Streaming: body is ``DerenderCompletionStreamRequest`` (one
103
+ ``generate_chunk`` + optional ``stream_state``). Returns a
104
+ ``DerenderCompletionStreamResponse`` (``chunk`` + ``stream_state``).
105
+ """
106
handler = derender(raw_request)
107
if handler is None:
108
raise NotImplementedError("The model does not support Completions Derender API")
109
69
- result = await handler.derender_completion_response(request)
110
+ if isinstance(request, DerenderCompletionStreamRequest):
111
+ stream_result = await handler.derender_completion_stream_response(request)
112
+ if isinstance(stream_result, ErrorResponse):
113
+ return JSONResponse(
114
+ content=stream_result.model_dump(),
115
+ status_code=stream_result.error.code,
116
+ )
117
+ chunk, stream_state = stream_result
118
+ response = DerenderCompletionStreamResponse(
119
+ chunk=chunk, stream_state=stream_state
120
+ )
121
+ return JSONResponse(content=response.model_dump())
122
123
+ result = await handler.derender_completion_response(request)
124
if isinstance(result, ErrorResponse):
125
return JSONResponse(content=result.model_dump(), status_code=result.error.code)
73
-
126
return JSONResponse(content=result.model_dump())
vllm/entrypoints/scale_out/derender/serving.py
+98
-3
@@ -4,8 +4,14 @@ import time
4
from typing import cast
5
6
import vllm.envs as envs
7
-from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionResponse
8
-from vllm.entrypoints.openai.completion.protocol import CompletionResponse
7
+from vllm.entrypoints.openai.chat_completion.protocol import (
8
+ ChatCompletionResponse,
9
+ ChatCompletionStreamResponse,
10
+)
11
+from vllm.entrypoints.openai.completion.protocol import (
12
+ CompletionResponse,
13
+ CompletionStreamResponse,
14
+)
15
from vllm.entrypoints.openai.engine.protocol import (
16
ErrorResponse,
17
UsageInfo,
@@ -28,7 +34,10 @@ from vllm.renderers.online_derenderer import OnlineDerenderer
34
from ..token_in_token_out.mm_serde import encode_mm_kwargs_item
35
from ..token_in_token_out.protocol import (
36
DerenderChatRequest,
37
+ DerenderChatStreamRequest,
38
DerenderCompletionRequest,
39
+ DerenderCompletionStreamRequest,
40
+ DerenderStreamState,
41
GenerateResponse,
42
MultiModalFeatures,
43
PlaceholderRangeInfo,
@@ -200,7 +209,9 @@ class ServingDerender(BaseServing):
209
total_prompt_tokens,
210
total_completion_tokens,
211
) = await self.online_derenderer.derender_completion(
203
- request.generate_responses, request.prompt_tokens
212
+ request.generate_responses,
213
+ request.prompt_tokens,
214
+ completion_request=request.completion_request,
215
)
216
217
first = request.generate_responses[0]
@@ -237,6 +248,90 @@ class ServingDerender(BaseServing):
248
kv_transfer_params=kv_params,
249
)
250
251
+ async def derender_chat_stream_response(
252
+ self,
253
+ request: DerenderChatStreamRequest,
254
+ ) -> tuple[ChatCompletionStreamResponse, DerenderStreamState] | ErrorResponse:
255
+ """Streaming counterpart to ``derender_chat_response``.
256
+
257
+ Processes one ``GenerateStreamResponse`` chunk and returns the
258
+ derendered chunk together with the updated client carried state.
259
+
260
+ ``parser is None`` or no ``chat_request`` until reasoning/tool call
261
+ functionality added in future PR.
262
+ """
263
+ error_check_ret = await self._check_model(request)
264
+ if error_check_ret is not None:
265
+ return error_check_ret
266
+
267
+ try:
268
+ chunk, updated_state = await self.online_derenderer.derender_chat_stream(
269
+ model=request.model,
270
+ generate_chunk=request.generate_chunk,
271
+ state=request.stream_state,
272
+ chat_request=request.chat_request,
273
+ prompt_tokens=request.prompt_tokens,
274
+ )
275
+ except NotImplementedError as exc:
276
+ return self.create_error_response(exc)
277
+ except ValueError as exc:
278
+ return self.create_error_response(str(exc))
279
+ except (KeyError, IndexError) as exc:
280
+ return self.create_error_response(
281
+ f"invalid stream_state: detokenization failed ({exc!r})"
282
+ )
283
+
284
+ logger.debug(
285
+ "derender_chat_stream request_id=%s model=%s delta_tokens=%d",
286
+ request.generate_chunk.request_id,
287
+ request.model,
288
+ sum(
289
+ len(c.token_ids) for c in request.generate_chunk.choices if c.token_ids
290
+ ),
291
+ )
292
+ return chunk, updated_state
293
+
294
+ async def derender_completion_stream_response(
295
+ self,
296
+ request: DerenderCompletionStreamRequest,
297
+ ) -> tuple[CompletionStreamResponse, DerenderStreamState] | ErrorResponse:
298
+ """Streaming counterpart to ``derender_completion_response``.
299
+
300
+ Processes one ``GenerateStreamResponse`` chunk (one output sequence's
301
+ delta) and returns the derendered chunk and updated state.
302
+ """
303
+ error_check_ret = await self._check_model(request)
304
+ if error_check_ret is not None:
305
+ return error_check_ret
306
+
307
+ try:
308
+ (
309
+ chunk,
310
+ updated_state,
311
+ ) = await self.online_derenderer.derender_completion_stream(
312
+ model=request.model,
313
+ generate_chunk=request.generate_chunk,
314
+ state=request.stream_state,
315
+ prompt_tokens=request.prompt_tokens,
316
+ completion_request=request.completion_request,
317
+ )
318
+ except ValueError as exc:
319
+ return self.create_error_response(str(exc))
320
+ except (KeyError, IndexError) as exc:
321
+ return self.create_error_response(
322
+ f"invalid stream_state: detokenization failed ({exc!r})"
323
+ )
324
+
325
+ logger.debug(
326
+ "derender_completion_stream request_id=%s model=%s delta_tokens=%d",
327
+ request.generate_chunk.request_id,
328
+ request.model,
329
+ sum(
330
+ len(c.token_ids) for c in request.generate_chunk.choices if c.token_ids
331
+ ),
332
+ )
333
+ return chunk, updated_state
334
+
335
@staticmethod
336
def _extract_mm_features(
337
engine_input: EngineInput,
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+165
-11
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from typing import Any
3
+from typing import Any, Literal, TypeAlias
4
5
from pydantic import (
6
BaseModel,
@@ -14,8 +14,12 @@ from vllm.config import ModelConfig
14
from vllm.entrypoints.openai.chat_completion.protocol import (
15
ChatCompletionLogProbs,
16
ChatCompletionRequest,
17
+ ChatCompletionStreamResponse,
18
+)
19
+from vllm.entrypoints.openai.completion.protocol import (
20
+ CompletionRequest,
21
+ CompletionStreamResponse,
22
)
18
-from vllm.entrypoints.openai.completion.protocol import CompletionRequest
23
from vllm.entrypoints.openai.engine.protocol import StreamOptions, UsageInfo
24
from vllm.logprobs import Logprob
25
from vllm.renderers import TokenizeParams
@@ -252,21 +256,16 @@ class GenerateResponse(BaseModel):
256
)
257
258
255
-####### Derender (postprocessing) #######
256
-
257
-
259
class DerenderChatRequest(BaseModel):
260
"""Request for the /v1/chat/completions/derender endpoint (non-streaming).
261
261
- Wraps a complete GenerateResponse and caller-supplied metadata needed to
262
- produce a fully-formed ChatCompletionResponse without a GPU.
263
-
264
- Streaming derender would require a separate endpoint design with
265
- incremental token delivery, ``OutputProcessor``-based detokenization,
266
- and ``parser.parse_delta()`` instead of ``parser.parse()``.
262
+ Wraps a complete GenerateResponse and caller supplied metadata needed to
263
+ produce a fully formed ChatCompletionResponse without a GPU.
264
"""
265
266
# --8<-- [start:derender-chat-request]
267
+ stream: Literal[False] = False
268
+
269
model: str
270
"""Served model name."""
271
@@ -299,6 +298,8 @@ class DerenderCompletionRequest(BaseModel):
298
"""
299
300
# --8<-- [start:derender-completion-request]
301
+ stream: Literal[False] = False
302
+
303
model: str
304
"""Served model name."""
305
@@ -330,3 +331,156 @@ class DerenderCompletionRequest(BaseModel):
331
f"generate_responses length ({len(self.generate_responses)})"
332
)
333
return self
334
+
335
+
336
+class DerenderStreamState(BaseModel):
337
+ """Per sequence state for stateless streaming derender.
338
+
339
+ The client carries this between successive per chunk HTTP calls to the
340
+ streaming derender endpoint. All fields are plain JSON serializable data.
341
+ No opaque tokenizer or parser internals are stored here.
342
+
343
+ The detokenization strategy carries the incremental decode offsets
344
+ directly rather than re-sending the whole token history each chunk.
345
+ ``detokenize_incrementally`` only ever reads the trailing token window
346
+ ``prev_tokens[prefix_offset:]``, so we carry just that tail plus the two
347
+ offsets. Each chunk resumes exactly where the last one stopped, including
348
+ any partially processed multi-byte character (tracked by ``read_offset``),
349
+ then trims and rebases the window so it never grows with generation length.
350
+
351
+ Performance:
352
+ - Compute per chunk is O(delta). One ``detokenize_incrementally`` call per
353
+ new token, independent of how many tokens preceded it.
354
+ - Transport per chunk is O(window). The carried tail is bounded by the
355
+ incremental detokenization offset, so cumulative bytes over the wire are
356
+ O(n) rather than the O(n^2) a full history round trip would incur.
357
+ """
358
+
359
+ prev_tokens: list[str] = Field(default_factory=list)
360
+ """Trailing decode window. Token strings from ``prefix_offset`` onward.
361
+
362
+ Bounded, trimmed and rebased each chunk to the tail
363
+ ``detokenize_incrementally`` still reads, so it does not grow with the
364
+ number of chunks.
365
+ """
366
+
367
+ prefix_offset: int = Field(default=0, ge=0)
368
+ """Prefix offset into ``prev_tokens`` for incremental detokenization."""
369
+
370
+ read_offset: int = Field(default=0, ge=0)
371
+ """Read offset into ``prev_tokens`` for incremental detokenization."""
372
+
373
+ @field_validator("prev_tokens")
374
+ @classmethod
375
+ def _bound_prev_tokens(cls, v: list[str]) -> list[str]:
376
+ # INITIAL_INCREMENTAL_DETOKENIZATION_OFFSET is small (5) and the trimmed
377
+ # window is O(offset). A generous limit rejects unusually large or malformed
378
+ # payloads without restricting legitimate multi-byte sequences.
379
+ limit = 1024
380
+ if len(v) > limit:
381
+ raise ValueError(f"prev_tokens length ({len(v)}) exceeds maximum ({limit})")
382
+ return v
383
+
384
+ role_sent: bool = False
385
+ """True once the initial ``role: "assistant"`` delta has been emitted.
386
+
387
+ Prevents re-emitting the role on subsequent chunks even when the detok
388
+ window is transiently empty (e.g. usage only final chunk).
389
+ """
390
+
391
+ # TODO: Properties used in follow on PR for tool call parsing
392
+ last_content: str | None = None
393
+ """Last emitted cumulative assistant content text."""
394
+
395
+ last_reasoning: str | None = None
396
+ """Last emitted cumulative reasoning text."""
397
+
398
+ last_tool_call_ids: list[str] = Field(default_factory=list)
399
+ """Stable tool-call IDs, assigned once when each call first appears.
400
+
401
+ Prevents ID regeneration across re-parsing.
402
+ """
403
+
404
+
405
+class DerenderChatStreamRequest(BaseModel):
406
+ """One chunk streaming derender request for /v1/chat/completions/derender.
407
+
408
+ The client sends one request per SSE chunk received from
409
+ ``/inference/v1/generate``. Each request carries the generate chunk
410
+ plus the ``stream_state`` returned by the previous call (``None`` on the
411
+ first call). The response contains the derendered chunk and the updated
412
+ state to be passed to the next call.
413
+
414
+ This implements stateless no server side session. All mutable state lives in
415
+ the client carried ``stream_state``.
416
+ """
417
+
418
+ stream: Literal[True]
419
+
420
+ model: str
421
+ generate_chunk: GenerateStreamResponse
422
+ """One SSE chunk from ``/inference/v1/generate`` (``stream=True``)."""
423
+
424
+ stream_state: DerenderStreamState | None = None
425
+ """Client carried detok state from the previous call. ``None`` on first."""
426
+
427
+ prompt_tokens: int | None = None
428
+ """Prompt token count for usage. Forwarded from the render step."""
429
+
430
+ chat_request: ChatCompletionRequest | None = None
431
+ """The original (post adjust_request) ChatCompletionRequest from /render."""
432
+
433
+
434
+class DerenderCompletionStreamRequest(BaseModel):
435
+ """One chunk streaming derender request for /v1/completions/derender.
436
+
437
+ Parallel to ``DerenderChatStreamRequest`` for the completions endpoint.
438
+ Each call processes one SSE chunk (one output sequence's delta) and
439
+ returns the derendered chunk plus updated state.
440
+ """
441
+
442
+ stream: Literal[True]
443
+
444
+ model: str
445
+ generate_chunk: GenerateStreamResponse
446
+ """One SSE chunk from ``/inference/v1/generate``."""
447
+
448
+ stream_state: DerenderStreamState | None = None
449
+ """Client-carried detok state. ``None`` on the first call."""
450
+
451
+ prompt_tokens: int | None = None
452
+ """Prompt token count for usage."""
453
+
454
+ completion_request: CompletionRequest | None = None
455
+ """The original (post adjust_request) CompletionRequest from /render."""
456
+
457
+
458
+class DerenderChatStreamResponse(BaseModel):
459
+ """Response for one streaming chat derender chunk.
460
+
461
+ Pairs the derendered SSE chunk with the updated client carried state to
462
+ pass to the next call.
463
+ """
464
+
465
+ chunk: ChatCompletionStreamResponse
466
+ stream_state: DerenderStreamState
467
+
468
+
469
+class DerenderCompletionStreamResponse(BaseModel):
470
+ """Response for one streaming completions derender chunk.
471
+
472
+ Parallel to ``DerenderChatStreamResponse`` for the completions endpoint.
473
+ """
474
+
475
+ chunk: CompletionStreamResponse
476
+ stream_state: DerenderStreamState
477
+
478
+
479
+# Determines the type by checking the ``stream`` field's literal value. A body without
480
+# ``stream`` validates as the non-streaming member
481
+# (``stream`` defaults to ``False`` there), so FastAPI can validate and dispatch both
482
+# shapes on a single path.
483
+DerenderChatRequestUnion: TypeAlias = DerenderChatRequest | DerenderChatStreamRequest
484
+DerenderCompletionRequestUnion: TypeAlias = (
485
+ DerenderCompletionRequest | DerenderCompletionStreamRequest
486
+)
vllm/entrypoints/serve/engine/typing.py
+4
@@ -17,7 +17,9 @@ from vllm.entrypoints.openai.completion.protocol import (
17
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
18
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
19
DerenderChatRequest,
20
+ DerenderChatStreamRequest,
21
DerenderCompletionRequest,
22
+ DerenderCompletionStreamRequest,
23
GenerateRequest,
24
GenerateResponse,
25
)
@@ -54,6 +56,7 @@ CompletionLikeRequest: TypeAlias = (
56
| TokenizeCompletionRequest
57
| DetokenizeRequest
58
| DerenderCompletionRequest
59
+ | DerenderCompletionStreamRequest
60
)
61
62
ChatLikeRequest: TypeAlias = (
@@ -61,6 +64,7 @@ ChatLikeRequest: TypeAlias = (
64
| BatchChatCompletionRequest
65
| TokenizeChatRequest
66
| DerenderChatRequest
67
+ | DerenderChatStreamRequest
68
)
69
70
SpeechToTextRequest: TypeAlias = TranscriptionRequest | TranslationRequest
vllm/renderers/online_derenderer.py
+281
-6
@@ -10,19 +10,29 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
10
ChatCompletionNamedToolChoiceParam,
11
ChatCompletionRequest,
12
ChatCompletionResponseChoice,
13
+ ChatCompletionResponseStreamChoice,
14
+ ChatCompletionStreamResponse,
15
ChatMessage,
16
)
17
from vllm.entrypoints.openai.completion.protocol import (
18
CompletionLogProbs,
19
+ CompletionRequest,
20
CompletionResponseChoice,
21
+ CompletionResponseStreamChoice,
22
+ CompletionStreamResponse,
23
+)
24
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage, ToolCall, UsageInfo
25
+from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
26
+ DerenderStreamState,
27
+ GenerateResponse,
28
+ GenerateStreamResponse,
29
)
19
-from vllm.entrypoints.openai.engine.protocol import ToolCall
20
-from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateResponse
30
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
31
from vllm.logger import init_logger
32
from vllm.parser import Parser, ParserManager
33
from vllm.renderers import BaseRenderer
34
from vllm.tokenizers import TokenizerLike
35
+from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
36
from vllm.utils import random_uuid
37
from vllm.utils.async_utils import make_async
38
@@ -168,9 +178,15 @@ class OnlineDerenderer:
178
tool_calls=tc_items,
179
)
180
else:
171
- # No parser: plain detokenization.
181
+ # No parser: plain detokenization honouring the request's
182
+ # skip_special_tokens (default True when no request was given).
183
+ skip_special = (
184
+ chat_request.skip_special_tokens
185
+ if chat_request is not None
186
+ else True
187
+ )
188
decoded_text = tokenizer.decode(
173
- choice.token_ids, skip_special_tokens=True
189
+ choice.token_ids, skip_special_tokens=skip_special
190
)
191
message = ChatMessage(role="assistant", content=decoded_text)
192
@@ -185,23 +201,200 @@ class OnlineDerenderer:
201
202
return choices
203
204
+ def _detokenize_delta(
205
+ self,
206
+ tokenizer: TokenizerLike,
207
+ delta_token_ids: list[int],
208
+ state: DerenderStreamState,
209
+ skip_special_tokens: bool = True,
210
+ spaces_between_special_tokens: bool = True,
211
+ ) -> tuple[str, DerenderStreamState]:
212
+ """Incrementally detokenize ``delta_token_ids`` from prior stream state.
213
+
214
+ Resumes decoding from the offsets carried in ``state`` rather than
215
+ replaying token history. ``state.prev_tokens`` holds the trailing decode
216
+ window (from ``prefix_offset`` onward) that ``detokenize_incrementally``
217
+ still needs to reproduce any partially read multi-byte character
218
+ (tracked by ``read_offset``). The delta tokens are fed straight onto it.
219
+
220
+ The window is bounded. ``detokenize_incrementally`` never reads before
221
+ ``prefix_offset``, so after processing we trim ``prev_tokens`` to that
222
+ tail and rebase the offsets to it. State transport therefore stays
223
+ O(window) per chunk instead of re-sending the full token history.
224
+
225
+ Args:
226
+ tokenizer: The tokenizer to decode with.
227
+ delta_token_ids: New token IDs from this generate chunk.
228
+ state: Client carried detok state from the previous call.
229
+ skip_special_tokens: Passed through to the tokenizer.
230
+ spaces_between_special_tokens: Passed through to the tokenizer.
231
+
232
+ Returns:
233
+ (new_text, updated_state) — the delta text for this chunk and the
234
+ state to pass to the next call.
235
+ """
236
+ prev_tokens = list(state.prev_tokens)
237
+ prefix_offset = state.prefix_offset
238
+ read_offset = state.read_offset
239
+
240
+ text_parts: list[str] = []
241
+ for tok_id in delta_token_ids:
242
+ # prev_tokens is a (possibly empty) list, never None, so this
243
+ # always takes the non first iter path and only consumes
244
+ # all_input_ids[-1].
245
+ new_toks, text, prefix_offset, read_offset = detokenize_incrementally(
246
+ tokenizer=tokenizer,
247
+ all_input_ids=[tok_id],
248
+ prev_tokens=prev_tokens,
249
+ prefix_offset=prefix_offset,
250
+ read_offset=read_offset,
251
+ skip_special_tokens=skip_special_tokens,
252
+ spaces_between_special_tokens=spaces_between_special_tokens,
253
+ )
254
+ prev_tokens = prev_tokens + new_toks
255
+ text_parts.append(text)
256
+
257
+ # Trim to the tail still readable by detokenize_incrementally
258
+ # (everything before prefix_offset is dead) and rebase the offsets so
259
+ # the carried window stays bounded regardless of generation length.
260
+ trimmed = prev_tokens[prefix_offset:]
261
+ updated_state = state.model_copy(
262
+ update={
263
+ "prev_tokens": trimmed,
264
+ "prefix_offset": 0,
265
+ "read_offset": read_offset - prefix_offset,
266
+ }
267
+ )
268
+ return "".join(text_parts), updated_state
269
+
270
+ async def derender_chat_stream(
271
+ self,
272
+ model: str,
273
+ generate_chunk: GenerateStreamResponse,
274
+ state: DerenderStreamState | None = None,
275
+ chat_request: ChatCompletionRequest | None = None,
276
+ prompt_tokens: int | None = None,
277
+ ) -> tuple[ChatCompletionStreamResponse, DerenderStreamState]:
278
+ """Process one GenerateStreamResponse chunk for streaming chat derender.
279
+
280
+ TODO: parse path for reasoning and tool calls is implemented in future PR.
281
+
282
+ Unlike OpenAI's API, which always emits ``role: "assistant"`` on the
283
+ very first chunk, this emits it on the first chunk with a non empty
284
+ ``choices`` list. A leading usage only chunk therefore defers the
285
+ role to the following content chunk instead of sending an empty
286
+ role only delta.
287
+
288
+ Args:
289
+ model: Model name for the response object.
290
+ generate_chunk: One SSE chunk from ``/inference/v1/generate``.
291
+ state: Client carried detok state (``None`` for first call).
292
+ chat_request: Original ChatCompletionRequest from ``/render``.
293
+ prompt_tokens: Prompt token count for the usage chunk.
294
+
295
+ Returns:
296
+ (chunk, updated_state) — the derendered SSE chunk and the state
297
+ the client must pass to the next call.
298
+ """
299
+ if state is None:
300
+ state = DerenderStreamState()
301
+
302
+ if self.parser is not None:
303
+ # TODO: Follow on PR will implement the parse path. Check on the
304
+ # parser alone (fail closed). A parser configured model must never
305
+ # fall through to plain detok on the streaming path, even when
306
+ # ``chat_request`` is omitted or reasoning/tool markup would leak
307
+ # into ``delta.content``.
308
+ raise NotImplementedError(
309
+ "Streaming chat derender is not yet supported for models with "
310
+ "a reasoning or tool parser configured. Use the non-streaming "
311
+ "derender endpoint (stream=false) for parsed output."
312
+ )
313
+
314
+ # A single DerenderStreamState is threaded through every choice in
315
+ # this chunk. Correct only when there is at most one choice per SSE
316
+ # event (n=1, one call per index), as the streaming derender
317
+ # protocol assumes. Multiple choices sharing one chunk would corrupt
318
+ # each other's detok window.
319
+ if len(generate_chunk.choices) > 1:
320
+ raise ValueError(
321
+ "derender_chat_stream expects at most one choice per chunk"
322
+ )
323
+
324
+ tokenizer = self.renderer.get_tokenizer()
325
+ skip_special = (
326
+ chat_request.skip_special_tokens if chat_request is not None else True
327
+ )
328
+ stream_choices: list[ChatCompletionResponseStreamChoice] = []
329
+ updated_state = state
330
+
331
+ for choice in generate_chunk.choices:
332
+ delta_tids = choice.token_ids or []
333
+ new_text, updated_state = self._detokenize_delta(
334
+ tokenizer, delta_tids, updated_state, skip_special_tokens=skip_special
335
+ )
336
+
337
+ include_role = not updated_state.role_sent
338
+ if include_role:
339
+ updated_state = updated_state.model_copy(update={"role_sent": True})
340
+
341
+ delta = DeltaMessage(
342
+ role="assistant" if include_role else None,
343
+ content=new_text if new_text else None,
344
+ )
345
+ stream_choices.append(
346
+ ChatCompletionResponseStreamChoice(
347
+ index=choice.index,
348
+ delta=delta,
349
+ finish_reason=choice.finish_reason,
350
+ )
351
+ )
352
+
353
+ usage: UsageInfo | None = None
354
+ if generate_chunk.usage is not None:
355
+ u = generate_chunk.usage
356
+ pt = prompt_tokens if prompt_tokens is not None else (u.prompt_tokens or 0)
357
+ ct = u.completion_tokens or 0
358
+ usage = UsageInfo(
359
+ prompt_tokens=pt,
360
+ completion_tokens=ct,
361
+ total_tokens=pt + ct,
362
+ )
363
+
364
+ chunk = ChatCompletionStreamResponse(
365
+ id=generate_chunk.request_id,
366
+ model=model,
367
+ choices=stream_choices,
368
+ usage=usage,
369
+ )
370
+ return chunk, updated_state
371
+
372
async def derender_completion(
373
self,
374
generate_responses: list[GenerateResponse],
375
prompt_tokens: list[int] | None = None,
376
+ completion_request: CompletionRequest | None = None,
377
) -> tuple[list[CompletionResponseChoice], int, int]:
193
- return await self._derender_completion_async(generate_responses, prompt_tokens)
378
+ return await self._derender_completion_async(
379
+ generate_responses, prompt_tokens, completion_request
380
+ )
381
382
def _derender_completion(
383
self,
384
generate_responses: list[GenerateResponse],
385
prompt_tokens: list[int] | None = None,
386
+ completion_request: CompletionRequest | None = None,
387
) -> tuple[list[CompletionResponseChoice], int, int]:
388
n = len(generate_responses)
389
prompt_tokens_list: list[int] = (
390
prompt_tokens if prompt_tokens is not None else [0] * n
391
)
392
393
+ skip_special = (
394
+ completion_request.skip_special_tokens
395
+ if completion_request is not None
396
+ else True
397
+ )
398
tokenizer = self.renderer.get_tokenizer()
399
choices: list[CompletionResponseChoice] = []
400
total_prompt_tokens = 0
@@ -217,7 +410,7 @@ class OnlineDerenderer:
410
)
411
412
decoded_text = tokenizer.decode(
220
- choice.token_ids, skip_special_tokens=True
413
+ choice.token_ids, skip_special_tokens=skip_special
414
)
415
completion_logprobs = None
416
if choice.logprobs is not None:
@@ -239,6 +432,88 @@ class OnlineDerenderer:
432
433
return choices, total_prompt_tokens, total_completion_tokens
434
435
+ async def derender_completion_stream(
436
+ self,
437
+ model: str,
438
+ generate_chunk: GenerateStreamResponse,
439
+ state: DerenderStreamState | None = None,
440
+ prompt_tokens: int | None = None,
441
+ completion_request: CompletionRequest | None = None,
442
+ ) -> tuple[CompletionStreamResponse, DerenderStreamState]:
443
+ """Process one GenerateStreamResponse chunk for streaming completions.
444
+
445
+ Each call takes one SSE chunk from ``/inference/v1/generate`` plus the
446
+ client carried ``stream_state`` and returns a ``CompletionStreamResponse``
447
+ chunk and the updated state.
448
+
449
+ The generate stream emits one choice per SSE event, so this method
450
+ processes one output sequence at a time. For ``n > 1`` the client
451
+ maintains one ``DerenderStreamState`` per ``choice.index``.
452
+
453
+ Args:
454
+ model: Model name for the response object.
455
+ generate_chunk: One SSE chunk from ``/inference/v1/generate``.
456
+ state: Client carried detok state (``None`` → first call).
457
+ prompt_tokens: Prompt token count for usage (from the render step).
458
+ completion_request: Original CompletionRequest from ``/render``;
459
+ supplies ``skip_special_tokens``.
460
+
461
+ Returns:
462
+ (chunk, updated_state) — the derendered chunk and updated state.
463
+ """
464
+ if state is None:
465
+ state = DerenderStreamState()
466
+
467
+ # See the equivalent check in derender_chat_stream: a single
468
+ # DerenderStreamState is threaded through every choice in this
469
+ # chunk, so more than one choice per chunk would corrupt the
470
+ # detok window across choices.
471
+ if len(generate_chunk.choices) > 1:
472
+ raise ValueError(
473
+ "derender_completion_stream expects at most one choice per chunk"
474
+ )
475
+
476
+ tokenizer = self.renderer.get_tokenizer()
477
+ skip_special = (
478
+ completion_request.skip_special_tokens
479
+ if completion_request is not None
480
+ else True
481
+ )
482
+ stream_choices: list[CompletionResponseStreamChoice] = []
483
+ updated_state = state
484
+
485
+ for choice in generate_chunk.choices:
486
+ delta_tids = choice.token_ids or []
487
+ new_text, updated_state = self._detokenize_delta(
488
+ tokenizer, delta_tids, updated_state, skip_special_tokens=skip_special
489
+ )
490
+ stream_choices.append(
491
+ CompletionResponseStreamChoice(
492
+ index=choice.index,
493
+ text=new_text,
494
+ finish_reason=choice.finish_reason,
495
+ )
496
+ )
497
+
498
+ usage: UsageInfo | None = None
499
+ if generate_chunk.usage is not None:
500
+ u = generate_chunk.usage
501
+ pt = prompt_tokens if prompt_tokens is not None else (u.prompt_tokens or 0)
502
+ ct = u.completion_tokens or 0
503
+ usage = UsageInfo(
504
+ prompt_tokens=pt,
505
+ completion_tokens=ct,
506
+ total_tokens=pt + ct,
507
+ )
508
+
509
+ chunk = CompletionStreamResponse(
510
+ id=generate_chunk.request_id,
511
+ model=model,
512
+ choices=stream_choices,
513
+ usage=usage,
514
+ )
515
+ return chunk, updated_state
516
+
517
518
def _parse_token_id_placeholder(token: str) -> int | None:
519
"""Extract token ID from a 'token_id:N' placeholder string."""