main
py 2,337 lines 85.2 KB
Raw
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 The parser path is covered both with a deterministic stub parser and
12 with the real `HarmonyParser` (skipped without `openai_harmony`).
13
14 2. Integration tests (require a running render server): covers the full
15 HTTP round-trip through the streaming endpoint. Marked with
16 `@pytest.mark.asyncio` and gated by the `server` / `client`
17 fixtures from the sibling `test_derender.py`.
18 """
19
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,
37 DeltaToolCall,
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,
55 GenerateResponseStreamChoice,
56 GenerateStreamResponse,
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"
69
70
71 def _merge_fake_deltas(
72 accumulated: DeltaMessage | None, new: DeltaMessage | None
73 ) -> DeltaMessage | None:
74 """Fold one token's worth of `_FakeParser` output into a per call
75 accumulator, the same way a real parser merges multiple tokens'
76 effects into the single `DeltaMessage` it returns per `parse_delta`
77 call.
78 """
79 if new is None:
80 return accumulated
81 if accumulated is None:
82 accumulated = DeltaMessage()
83
84 if new.content:
85 accumulated.content = (accumulated.content or "") + new.content
86 if new.reasoning:
87 accumulated.reasoning = (accumulated.reasoning or "") + new.reasoning
88
89 for tc in new.tool_calls:
90 starts_new_call = tc.id is not None or (
91 tc.function is not None and tc.function.name is not None
92 )
93 existing = (
94 None
95 if starts_new_call
96 else next((t for t in accumulated.tool_calls if t.index == tc.index), None)
97 )
98 if existing is None:
99 accumulated.tool_calls.append(tc)
100 continue
101 if existing.function is None:
102 existing.function = DeltaFunctionCall()
103 if tc.function is not None and tc.function.arguments:
104 existing.function.arguments = (
105 existing.function.arguments or ""
106 ) + tc.function.arguments
107
108 return accumulated
109
110
111 class _FakeParser(Parser):
112 """Deterministic `Parser` stub for unit testing the replay/merge/pin
113 mechanics of `OnlineDerenderer._derender_chat_stream_parsed` in
114 isolation from any real reasoning/tool parser's markup grammar.
115
116 Processes every id in `delta_token_ids` in order and folds their
117 effects into one `DeltaMessage`, like a real parser handling a
118 multi-token delta in a single call. Ignores `delta_text`:
119
120 - `TOOL_START` opens a new tool call at index 0 (id + name).
121 - `TOOL_ARG` appends one `"a"` to that tool call's arguments.
122 - `REASON` emits one `"r"` of reasoning (suppressed when the request
123 has `include_reasoning=False`, mirroring the real parsers).
124 - `CONTENT` emits one `"c"` of content.
125 - An empty `delta_token_ids` with `finished=True` (the finish only
126 flush call) emits a `"FLUSH"` content sentinel so tests can confirm
127 it happened.
128 - Anything else emits nothing.
129 """
130
131 TOOL_START = 9
132 TOOL_ARG = 10
133 REASON = 11
134 CONTENT = 12
135
136 def parse_delta(
137 self,
138 delta_text,
139 delta_token_ids,
140 request,
141 prompt_token_ids=None,
142 *,
143 finished,
144 ):
145 if not delta_token_ids:
146 return DeltaMessage(content="FLUSH") if finished else None
147
148 accumulated: DeltaMessage | None = None
149 for tok in delta_token_ids:
150 accumulated = _merge_fake_deltas(
151 accumulated, self._parse_one_token(tok, request)
152 )
153 return accumulated
154
155 def _parse_one_token(self, tok: int, request) -> DeltaMessage | None:
156 if tok == self.TOOL_START:
157 return DeltaMessage(
158 tool_calls=[
159 DeltaToolCall(
160 id=f"call-{random_uuid()}",
161 type="function",
162 function=DeltaFunctionCall(name="get_weather", arguments=""),
163 index=0,
164 )
165 ]
166 )
167 if tok == self.TOOL_ARG:
168 return DeltaMessage(
169 tool_calls=[
170 DeltaToolCall(index=0, function=DeltaFunctionCall(arguments="a"))
171 ]
172 )
173 if tok == self.REASON:
174 if not request.include_reasoning:
175 return None
176 return DeltaMessage(reasoning="r")
177 if tok == self.CONTENT:
178 return DeltaMessage(content="c")
179 return None
180
181
182 # ---------------------------------------------------------------------------
183 # Helpers shared across tests
184 # ---------------------------------------------------------------------------
185
186
187 def _make_stream_chunk(
188 token_ids: list[int],
189 index: int = 0,
190 finish_reason: str | None = None,
191 request_id: str = "test-req",
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(
199 request_id=request_id,
200 choices=[
201 GenerateResponseStreamChoice(
202 index=index,
203 token_ids=token_ids,
204 finish_reason=finish_reason,
205 logprobs=logprobs,
206 )
207 ],
208 usage=UsageInfo(**usage) if usage else None,
209 metrics=metrics,
210 prompt_token_ids=prompt_token_ids,
211 )
212
213
214 def _placeholder_logprobs(token_ids: list[int]) -> dict:
215 """Per-token logprob entries using token_id:N placeholders, as sent by
216 the generate worker."""
217 return {
218 "content": [
219 {
220 "token": f"token_id:{tid}",
221 "logprob": -0.5,
222 "bytes": None,
223 "top_logprobs": [],
224 }
225 for tid in token_ids
226 ]
227 }
228
229
230 def _make_usage_chunk(
231 completion_tokens: int,
232 prompt_tokens: int = 0,
233 request_id: str = "test-req",
234 ) -> GenerateStreamResponse:
235 """Build a usage only final SSE chunk (empty choices)."""
236 return GenerateStreamResponse(
237 request_id=request_id,
238 choices=[],
239 usage=UsageInfo(
240 prompt_tokens=prompt_tokens,
241 completion_tokens=completion_tokens,
242 total_tokens=prompt_tokens + completion_tokens,
243 ),
244 )
245
246
247 # ---------------------------------------------------------------------------
248 # Unit tests — no running server
249 # ---------------------------------------------------------------------------
250
251
252 @pytest.fixture(scope="module")
253 def tokenizer():
254 """Load the tiny tokenizer used across unit tests."""
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."""
261 renderer = MagicMock()
262 renderer.get_tokenizer.return_value = tokenizer
263
264 executor = ThreadPoolExecutor(max_workers=1)
265 request.addfinalizer(executor.shutdown)
266 renderer._executor = executor
267
268 model_config = MagicMock()
269 model_config.hf_config.model_type = "llama"
270 model_config.model = MODEL_NAME
271
272 return OnlineDerenderer(
273 model_config=model_config,
274 renderer=renderer,
275 request_logger=None,
276 chat_template=None,
277 chat_template_content_format="string",
278 trust_request_chat_template=False,
279 enable_auto_tools=False,
280 tool_parser=None,
281 reasoning_parser=None,
282 )
283
284
285 @pytest.fixture(scope="module")
286 def parsed_derenderer(tokenizer):
287 """OnlineDerenderer with `_FakeParser` wired in as `self.parser`.
288
289 The parser-configured path runs `_derender_chat_stream_parsed` via
290 `make_async(..., executor=renderer._executor)`, so
291 `renderer._executor` must be a real `ThreadPoolExecutor`.
292 `loop.run_in_executor` cannot submit work to a `MagicMock`.
293 """
294 renderer = MagicMock()
295 renderer.get_tokenizer.return_value = tokenizer
296 renderer._executor = ThreadPoolExecutor(max_workers=2)
297
298 model_config = MagicMock()
299 model_config.hf_config.model_type = "llama"
300 model_config.model = MODEL_NAME
301
302 dr = OnlineDerenderer(
303 model_config=model_config,
304 renderer=renderer,
305 request_logger=None,
306 chat_template=None,
307 chat_template_content_format="string",
308 )
309 dr.parser = _FakeParser
310 return dr
311
312
313 def test_non_streaming_derender_initializes_parser_prefix(
314 derenderer, tokenizer, monkeypatch
315 ):
316 from unittest.mock import MagicMock
317
318 from vllm.entrypoints.generate.base.protocol import FunctionCall
319 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
320
321 parser = MagicMock()
322 parser.parse.return_value = (
323 None,
324 None,
325 [FunctionCall(name="tool", arguments="{}")],
326 )
327 monkeypatch.setattr(derenderer, "parser", MagicMock(return_value=parser))
328 generated_ids = tokenizer.encode("answer", add_special_tokens=False)
329 response = GenerateResponse(
330 request_id="test",
331 prompt_token_ids=[11, 12],
332 choices=[
333 GenerateResponseChoice(
334 index=0,
335 token_ids=generated_ids,
336 finish_reason="length",
337 )
338 ],
339 )
340 request = ChatCompletionRequest(
341 model=MODEL_NAME,
342 messages=[{"role": "user", "content": "question"}],
343 )
344
345 choices = derenderer._derender_chat(response, request)
346
347 parser.set_prompt_token_ids.assert_called_once_with([11, 12])
348 assert choices[0].message.tool_calls[0].function.name == "tool"
349 assert choices[0].finish_reason == "length"
350
351
352 class TestDetokenizeDelta:
353 """_detokenize_delta: chunked decode must equal one shot decode."""
354
355 def _one_shot(self, tokenizer, token_ids: list[int]) -> str:
356 return tokenizer.decode(token_ids, skip_special_tokens=True)
357
358 def _chunked(self, derenderer, tokenizer, chunks: list[list[int]]) -> str:
359 state = DerenderStreamState()
360 parts: list[str] = []
361 for delta in chunks:
362 text, state = derenderer._detokenize_delta(
363 tokenizer, delta, state, skip_special_tokens=True
364 )
365 parts.append(text)
366 return "".join(parts)
367
368 def test_single_chunk(self, derenderer, tokenizer):
369 """All tokens in one chunk == one shot decode."""
370 token_ids = tokenizer.encode("Hello world")[:8]
371 assert self._chunked(derenderer, tokenizer, [token_ids]) == self._one_shot(
372 tokenizer, token_ids
373 )
374
375 def test_two_equal_chunks(self, derenderer, tokenizer):
376 """Split in half and reassemble == one shot."""
377 token_ids = tokenizer.encode("Hello world from streaming derender")[:12]
378 mid = len(token_ids) // 2
379 chunks = [token_ids[:mid], token_ids[mid:]]
380 assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
381 tokenizer, token_ids
382 )
383
384 def test_single_token_per_chunk(self, derenderer, tokenizer):
385 """One token per chunk (most granular streaming) == one shot."""
386 token_ids = tokenizer.encode("incremental detokenization test")[:10]
387 chunks = [[t] for t in token_ids]
388 assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
389 tokenizer, token_ids
390 )
391
392 def test_empty_delta_passthrough(self, derenderer, tokenizer):
393 """An empty delta (usage only chunk) emits empty string and preserves state."""
394 token_ids = tokenizer.encode("Hello")[:4]
395 _, state = derenderer._detokenize_delta(
396 tokenizer, token_ids, DerenderStreamState(), skip_special_tokens=True
397 )
398 text, new_state = derenderer._detokenize_delta(
399 tokenizer, [], state, skip_special_tokens=True
400 )
401 assert text == ""
402 assert new_state.prev_tokens == state.prev_tokens
403 assert new_state.prefix_offset == state.prefix_offset
404 assert new_state.read_offset == state.read_offset
405
406 def test_multibyte_char_split_across_chunks(self, derenderer, tokenizer):
407 """A CJK/emoji char straddling chunk boundaries == one shot.
408
409 Regression test for held back trailing incomplete UTF-8 byte
410 sequences being dropped when the rebuild window marks them as
411 already read (see #46159).
412 """
413 token_ids = tokenizer.encode("Hello ✅ world 日本語")[:16]
414 chunks = [[t] for t in token_ids]
415 assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
416 tokenizer, token_ids
417 )
418
419 def test_state_carries_across_calls(self, derenderer, tokenizer):
420 """Decode state threads across calls. Text still matches one shot."""
421 t1 = tokenizer.encode("Hello")[:2]
422 t2 = tokenizer.encode(" world")[:2]
423 state = DerenderStreamState()
424 text1, state = derenderer._detokenize_delta(tokenizer, t1, state)
425 text2, state = derenderer._detokenize_delta(tokenizer, t2, state)
426 assert text1 + text2 == self._one_shot(tokenizer, t1 + t2)
427 # Offsets are rebased to the carried tail each chunk
428 assert state.prefix_offset == 0
429
430 def test_state_window_stays_bounded(self, derenderer, tokenizer):
431 """prev_tokens must not grow with the number of chunks (bounded transport).
432
433 Guards that the carried decode window is a small constant
434 tail, so cumulative `stream_state` transport is O(n) and not O(n^2).
435 """
436 token_ids = tokenizer.encode(
437 "a reasonably long ascii stream of tokens used to exercise the "
438 "window bound across many single token chunks so the carried "
439 "state cannot grow linearly with the generation length"
440 )
441 assert len(token_ids) > 32
442 state = DerenderStreamState()
443 max_window = 0
444 for tok in token_ids:
445 _, state = derenderer._detokenize_delta(tokenizer, [tok], state)
446 max_window = max(max_window, len(state.prev_tokens))
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]
504 mid = len(token_ids) // 2
505
506 results = []
507 for _ in range(3):
508 state = DerenderStreamState()
509 text, state = derenderer._detokenize_delta(
510 tokenizer, token_ids[:mid], state
511 )
512 text2, _ = derenderer._detokenize_delta(tokenizer, token_ids[mid:], state)
513 results.append(text + text2)
514
515 assert len(set(results)) == 1, "All independent streams must produce same text"
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",
758 ["derender_chat_stream", "derender_completion_stream"],
759 )
760 async def test_streaming_detokenization_runs_off_event_loop(
761 derenderer, monkeypatch, stream_method
762 ):
763 """Streaming detokenization runs on the renderer executor."""
764 event_loop_thread_id = threading.get_ident()
765 executor_thread_id = derenderer.renderer._executor.submit(
766 threading.get_ident
767 ).result()
768
769 detokenize_thread_id = None
770 original_detokenize = online_derenderer_module.detokenize_incrementally
771
772 def record_detokenize_thread(*args, **kwargs):
773 nonlocal detokenize_thread_id
774 detokenize_thread_id = threading.get_ident()
775 return original_detokenize(*args, **kwargs)
776
777 monkeypatch.setattr(
778 online_derenderer_module,
779 "detokenize_incrementally",
780 record_detokenize_thread,
781 )
782
783 await getattr(derenderer, stream_method)(
784 model=MODEL_NAME,
785 generate_chunk=_make_stream_chunk([10]),
786 )
787
788 assert detokenize_thread_id is not None
789 assert detokenize_thread_id == executor_thread_id
790 assert detokenize_thread_id != event_loop_thread_id
791
792
793 class TestDerenderCompletionStream:
794 """derender_completion_stream: streaming output parity with one shot."""
795
796 @pytest.mark.asyncio
797 async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
798 """Sum of streaming text chunks == one shot tokenizer.decode."""
799 token_ids = tokenizer.encode("streaming completion test")[:10]
800 mid = len(token_ids) // 2
801
802 state = DerenderStreamState()
803 chunk1, state = await derenderer.derender_completion_stream(
804 model=MODEL_NAME,
805 generate_chunk=_make_stream_chunk(token_ids[:mid]),
806 state=state,
807 )
808 chunk2, _ = await derenderer.derender_completion_stream(
809 model=MODEL_NAME,
810 generate_chunk=_make_stream_chunk(token_ids[mid:], finish_reason="stop"),
811 state=state,
812 )
813
814 streamed_text = chunk1.choices[0].text + chunk2.choices[0].text
815 one_shot = tokenizer.decode(token_ids, skip_special_tokens=True)
816 assert streamed_text == one_shot
817
818 @pytest.mark.asyncio
819 async def test_usage_chunk_passthrough(self, derenderer, tokenizer):
820 """Usage only final chunk (empty choices) is passed through correctly."""
821 usage_chunk = _make_usage_chunk(completion_tokens=10, prompt_tokens=5)
822 chunk, state = await derenderer.derender_completion_stream(
823 model=MODEL_NAME,
824 generate_chunk=usage_chunk,
825 )
826 assert chunk.choices == []
827 assert chunk.usage is not None
828 assert chunk.usage.completion_tokens == 10
829 assert chunk.usage.prompt_tokens == 5
830
831 @pytest.mark.asyncio
832 async def test_prompt_tokens_in_usage(self, derenderer, tokenizer):
833 """prompt_tokens is correctly forwarded into usage on a usage chunk."""
834 token_ids = tokenizer.encode("hello")[:3]
835 usage_chunk = _make_usage_chunk(
836 completion_tokens=len(token_ids), prompt_tokens=7
837 )
838 chunk, _ = await derenderer.derender_completion_stream(
839 model=MODEL_NAME,
840 generate_chunk=usage_chunk,
841 prompt_tokens=7,
842 )
843 assert chunk.usage is not None
844 assert chunk.usage.prompt_tokens == 7
845
846 @pytest.mark.asyncio
847 async def test_none_state_initialises_correctly(self, derenderer, tokenizer):
848 """Passing state=None (first call) initialises an empty DerenderStreamState."""
849 token_ids = tokenizer.encode("hello")[:4]
850 chunk, state = await derenderer.derender_completion_stream(
851 model=MODEL_NAME,
852 generate_chunk=_make_stream_chunk(token_ids),
853 state=None,
854 )
855 assert isinstance(state, DerenderStreamState)
856 assert chunk.choices[0].text == tokenizer.decode(
857 token_ids, skip_special_tokens=True
858 )
859
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)."""
863 eos = tokenizer.eos_token_id
864 if eos is None:
865 pytest.skip("tokenizer has no eos token to exercise special stripping")
866 token_ids = tokenizer.encode("hi")[:2] + [eos]
867
868 async def _text(skip: bool) -> str:
869 req = CompletionRequest(
870 model=MODEL_NAME, prompt="x", skip_special_tokens=skip
871 )
872 chunk, _ = await derenderer.derender_completion_stream(
873 model=MODEL_NAME,
874 generate_chunk=_make_stream_chunk(token_ids),
875 completion_request=req,
876 )
877 return chunk.choices[0].text
878
879 # skip=False must retain the special token; skip=True must strip it.
880 assert await _text(False) != await _text(True)
881
882 @pytest.mark.asyncio
883 async def test_finish_reason_forwarded(self, derenderer, tokenizer):
884 """finish_reason from the generate chunk reaches the derendered choice."""
885 token_ids = tokenizer.encode("done")[:2]
886 chunk, _ = await derenderer.derender_completion_stream(
887 model=MODEL_NAME,
888 generate_chunk=_make_stream_chunk(token_ids, finish_reason="length"),
889 )
890 assert chunk.choices[0].finish_reason == "length"
891
892
893 class TestStreamLogprobs:
894 """Streaming derender must carry per-chunk logprobs with placeholders
895 resolved, matching what the generate streaming path emits."""
896
897 @pytest.mark.asyncio
898 async def test_chat_stream_logprobs_resolved_per_chunk(self, derenderer, tokenizer):
899 """Each streamed chunk carries logprobs with token_id:N resolved."""
900 token_ids = tokenizer.encode("hello world")[:6]
901 mid = len(token_ids) // 2
902
903 state = None
904 for part in (token_ids[:mid], token_ids[mid:]):
905 chunk, state = await derenderer.derender_chat_stream(
906 model=MODEL_NAME,
907 generate_chunk=_make_stream_chunk(
908 part, logprobs=_placeholder_logprobs(part)
909 ),
910 state=state,
911 )
912 logprobs = chunk.choices[0].logprobs
913 assert logprobs is not None and logprobs.content is not None
914 assert len(logprobs.content) == len(part)
915 for entry in logprobs.content:
916 assert not entry.token.startswith("token_id:"), (
917 f"placeholder not resolved: {entry.token!r}"
918 )
919
920 @pytest.mark.asyncio
921 async def test_chat_stream_logprobs_multibyte_across_chunks(
922 self, derenderer, tokenizer
923 ):
924 """Byte-fallback correction works when a multi-byte character's
925 tokens are split across chunks (context carried in stream_state)."""
926 token_ids = tokenizer.encode("👍", add_special_tokens=False)
927 if len(token_ids) < 2 or "�" not in tokenizer.decode([token_ids[-1]]):
928 pytest.skip("Tokenizer does not byte-split this character")
929
930 state = None
931 last_chunk = None
932 # One token per chunk: every entry after the first needs cross-chunk
933 # context to resolve without U+FFFD.
934 for i, tid in enumerate(token_ids):
935 last_chunk, state = await derenderer.derender_chat_stream(
936 model=MODEL_NAME,
937 generate_chunk=_make_stream_chunk(
938 [tid], logprobs=_placeholder_logprobs([tid])
939 ),
940 state=state,
941 )
942
943 assert last_chunk is not None
944 final_entry = last_chunk.choices[0].logprobs.content[0]
945 assert not final_entry.token.endswith("�"), (
946 "byte-fallback correction failed across chunk boundary: "
947 f"{final_entry.token!r}"
948 )
949
950 @pytest.mark.asyncio
951 async def test_completion_stream_logprobs_text_offset_absolute(
952 self, derenderer, tokenizer
953 ):
954 """text_offset continues across chunks instead of restarting at 0."""
955 token_ids = tokenizer.encode("hello world")[:6]
956 mid = len(token_ids) // 2
957
958 chunk1, state = await derenderer.derender_completion_stream(
959 model=MODEL_NAME,
960 generate_chunk=_make_stream_chunk(
961 token_ids[:mid], logprobs=_placeholder_logprobs(token_ids[:mid])
962 ),
963 )
964 chunk2, _ = await derenderer.derender_completion_stream(
965 model=MODEL_NAME,
966 generate_chunk=_make_stream_chunk(
967 token_ids[mid:], logprobs=_placeholder_logprobs(token_ids[mid:])
968 ),
969 state=state,
970 )
971
972 assert chunk1.choices[0].logprobs.text_offset[0] == 0
973 assert chunk2.choices[0].logprobs.text_offset[0] == len(chunk1.choices[0].text)
974
975 @pytest.mark.asyncio
976 async def test_stream_logprob_state_stays_bounded(self, derenderer, tokenizer):
977 """Carried logprob context never exceeds the 4-token window."""
978 token_ids = tokenizer.encode("the quick brown fox jumps over the lazy dog")
979 state = None
980 for tid in token_ids:
981 _, state = await derenderer.derender_chat_stream(
982 model=MODEL_NAME,
983 generate_chunk=_make_stream_chunk(
984 [tid], logprobs=_placeholder_logprobs([tid])
985 ),
986 state=state,
987 )
988 assert state is not None
989 assert len(state.logprob_context_token_ids) <= 4
990
991 @pytest.mark.asyncio
992 async def test_stream_without_logprobs_unchanged(self, derenderer, tokenizer):
993 """Chunks without logprobs keep logprobs=None on the output choice."""
994 token_ids = tokenizer.encode("hello")[:3]
995 chunk, _ = await derenderer.derender_chat_stream(
996 model=MODEL_NAME,
997 generate_chunk=_make_stream_chunk(token_ids),
998 )
999 assert chunk.choices[0].logprobs is None
1000
1001
1002 class TestDerenderChatStream:
1003 """derender_chat_stream: plain detok branch (no parser)."""
1004
1005 @pytest.mark.asyncio
1006 async def test_role_on_first_chunk_only(self, derenderer, tokenizer):
1007 """role='assistant' appears in the first chunk, not subsequent ones."""
1008 token_ids = tokenizer.encode("hello world")[:6]
1009 mid = len(token_ids) // 2
1010
1011 state = DerenderStreamState()
1012 chunk1, state = await derenderer.derender_chat_stream(
1013 model=MODEL_NAME,
1014 generate_chunk=_make_stream_chunk(token_ids[:mid]),
1015 state=state,
1016 )
1017 chunk2, _ = await derenderer.derender_chat_stream(
1018 model=MODEL_NAME,
1019 generate_chunk=_make_stream_chunk(token_ids[mid:], finish_reason="stop"),
1020 state=state,
1021 )
1022
1023 assert chunk1.choices[0].delta.role == "assistant"
1024 assert chunk2.choices[0].delta.role is None
1025
1026 @pytest.mark.asyncio
1027 async def test_metrics_passthrough(self, derenderer):
1028 metrics = PerRequestMetrics(
1029 speculative_decoding=SpeculativeDecodingMetrics(
1030 mean_acceptance_length=2.0,
1031 draft_acceptance_rate=0.5,
1032 acceptance_histogram=[0, 1],
1033 num_spec_steps=1,
1034 num_accepted_draft_tokens=1,
1035 num_draft_tokens=2,
1036 num_spec_tokens=1,
1037 )
1038 )
1039 chunk, _ = await derenderer.derender_chat_stream(
1040 model=MODEL_NAME,
1041 generate_chunk=_make_stream_chunk([], metrics=metrics),
1042 state=DerenderStreamState(),
1043 )
1044 assert chunk.metrics == metrics
1045
1046 @pytest.mark.asyncio
1047 async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
1048 """Sum of streaming content deltas == one shot decode."""
1049 token_ids = tokenizer.encode("streaming chat derender text")[:10]
1050 mid = len(token_ids) // 2
1051
1052 state = DerenderStreamState()
1053 chunk1, state = await derenderer.derender_chat_stream(
1054 model=MODEL_NAME,
1055 generate_chunk=_make_stream_chunk(token_ids[:mid]),
1056 state=state,
1057 )
1058 chunk2, _ = await derenderer.derender_chat_stream(
1059 model=MODEL_NAME,
1060 generate_chunk=_make_stream_chunk(token_ids[mid:]),
1061 state=state,
1062 )
1063
1064 streamed = (chunk1.choices[0].delta.content or "") + (
1065 chunk2.choices[0].delta.content or ""
1066 )
1067 one_shot = tokenizer.decode(token_ids, skip_special_tokens=True)
1068 assert streamed == one_shot
1069
1070 @pytest.mark.asyncio
1071 async def test_parser_configured_missing_chat_request_raises(
1072 self, parsed_derenderer
1073 ):
1074 """A parser configured model must never fall through to plain detok
1075 even when `chat_request` is omitted. If allowed this would leak raw
1076 reasoning/tool markup into `delta.content`. `ServingDerender`
1077 pre-checks this too (400 before touching the tokenizer). This pins
1078 the `OnlineDerenderer` level backstop."""
1079 with pytest.raises(ValueError, match="chat_request"):
1080 await parsed_derenderer.derender_chat_stream(
1081 model=MODEL_NAME,
1082 generate_chunk=_make_stream_chunk([_FakeParser.CONTENT]),
1083 state=None,
1084 chat_request=None,
1085 )
1086
1087
1088 def _chat_request(**kwargs):
1089 kwargs.setdefault("messages", [{"role": "user", "content": "hi"}])
1090 kwargs.setdefault("model", MODEL_NAME)
1091 return ChatCompletionRequest(**kwargs)
1092
1093
1094 class TestDerenderChatStreamParsed:
1095 """derender_chat_stream: parser branch (replay + parse_delta) exercised
1096 against the deterministic `_FakeParser` so these pin OnlineDerenderer's
1097 own replay/merge/pin/finish_reason logic independent of any real
1098 parser's markup grammar (covered separately by the parser_server backed
1099 integration tests below)."""
1100
1101 @pytest.mark.asyncio
1102 async def test_dispatches_and_emits_content(self, parsed_derenderer):
1103 chunk, state = await parsed_derenderer.derender_chat_stream(
1104 model=MODEL_NAME,
1105 generate_chunk=_make_stream_chunk(
1106 [_FakeParser.CONTENT], finish_reason="stop"
1107 ),
1108 chat_request=_chat_request(),
1109 )
1110 assert chunk.choices[0].delta.content == "c"
1111 assert chunk.choices[0].delta.role == "assistant"
1112 assert chunk.choices[0].finish_reason == "stop"
1113 assert state.output_token_ids == [_FakeParser.CONTENT]
1114
1115 @pytest.mark.asyncio
1116 async def test_role_sent_once(self, parsed_derenderer):
1117 chat_request = _chat_request()
1118 chunk1, state = await parsed_derenderer.derender_chat_stream(
1119 model=MODEL_NAME,
1120 generate_chunk=_make_stream_chunk([_FakeParser.CONTENT]),
1121 chat_request=chat_request,
1122 )
1123 chunk2, _ = await parsed_derenderer.derender_chat_stream(
1124 model=MODEL_NAME,
1125 generate_chunk=_make_stream_chunk(
1126 [_FakeParser.CONTENT], finish_reason="stop"
1127 ),
1128 state=state,
1129 chat_request=chat_request,
1130 )
1131 assert chunk1.choices[0].delta.role == "assistant"
1132 assert chunk2.choices[0].delta.role is None
1133
1134 @pytest.mark.asyncio
1135 async def test_finish_only_chunk_flushes(self, parsed_derenderer):
1136 """A finish only chunk (no new tokens) still calls `parse_delta`
1137 once with `finished=True` to flush buffered state."""
1138 chat_request = _chat_request()
1139 _, state = await parsed_derenderer.derender_chat_stream(
1140 model=MODEL_NAME,
1141 generate_chunk=_make_stream_chunk([_FakeParser.CONTENT]),
1142 chat_request=chat_request,
1143 )
1144 chunk2, _ = await parsed_derenderer.derender_chat_stream(
1145 model=MODEL_NAME,
1146 generate_chunk=_make_stream_chunk([], finish_reason="stop"),
1147 state=state,
1148 chat_request=chat_request,
1149 )
1150 assert chunk2.choices[0].delta.content == "FLUSH"
1151 assert chunk2.choices[0].finish_reason == "stop"
1152
1153 @pytest.mark.asyncio
1154 async def test_chunking_invariance(self, parsed_derenderer):
1155 """1 token per chunk vs a single whole chunk assemble identically.
1156
1157 Every chunk (live or replayed) is fed to `parse_delta` at its own
1158 producer granularity (see `_derender_chat_stream_parsed`'s
1159 docstring). This proves that choice doesn't change the assembled
1160 output for a well behaved incremental parser. Each call's effects
1161 fold together the same way regardless of how many tokens land in
1162 one call vs. many."""
1163 token_ids = [
1164 _FakeParser.REASON,
1165 _FakeParser.REASON,
1166 _FakeParser.TOOL_START,
1167 _FakeParser.TOOL_ARG,
1168 _FakeParser.TOOL_ARG,
1169 _FakeParser.CONTENT,
1170 ]
1171 chat_request = _chat_request(
1172 tools=[{"type": "function", "function": {"name": "get_weather"}}],
1173 tool_choice="auto",
1174 include_reasoning=True,
1175 )
1176
1177 async def _assemble(chunks: list[list[int]]) -> dict:
1178 state = None
1179 content = ""
1180 reasoning = ""
1181 tool_args = ""
1182 for i, tids in enumerate(chunks):
1183 finish = "stop" if i == len(chunks) - 1 else None
1184 chunk, state = await parsed_derenderer.derender_chat_stream(
1185 model=MODEL_NAME,
1186 generate_chunk=_make_stream_chunk(tids, finish_reason=finish),
1187 state=state,
1188 chat_request=chat_request,
1189 )
1190 delta = chunk.choices[0].delta
1191 content += delta.content or ""
1192 reasoning += delta.reasoning or ""
1193 for tc in delta.tool_calls:
1194 if tc.function and tc.function.arguments:
1195 tool_args += tc.function.arguments
1196 return {"content": content, "reasoning": reasoning, "tool_args": tool_args}
1197
1198 whole = await _assemble([token_ids])
1199 one_at_a_time = await _assemble([[t] for t in token_ids])
1200
1201 assert whole == one_at_a_time
1202 assert whole == {"content": "c", "reasoning": "rr", "tool_args": "aa"}
1203
1204 @pytest.mark.asyncio
1205 async def test_replay_preserves_chunk_boundaries(
1206 self, parsed_derenderer, monkeypatch
1207 ):
1208 """A multi-token chunk (e.g. a speculative decoding step) must be
1209 replayed as one `parse_delta` call, not one per token. Parsers
1210 with per-call state such as `history_tool_call_cnt` would
1211 otherwise diverge from standard serving."""
1212
1213 class _CallCountingParser(_FakeParser):
1214 """Emits how many `parse_delta` calls it has seen."""
1215
1216 def __init__(self, *args, **kwargs):
1217 super().__init__(*args, **kwargs)
1218 self.num_calls = 0
1219
1220 def parse_delta(
1221 self,
1222 delta_text,
1223 delta_token_ids,
1224 request,
1225 prompt_token_ids=None,
1226 *,
1227 finished,
1228 ):
1229 self.num_calls += 1
1230 return DeltaMessage(content=str(self.num_calls))
1231
1232 monkeypatch.setattr(parsed_derenderer, "parser", _CallCountingParser)
1233 chat_request = _chat_request()
1234
1235 _, state = await parsed_derenderer.derender_chat_stream(
1236 model=MODEL_NAME,
1237 generate_chunk=_make_stream_chunk([_FakeParser.CONTENT] * 3),
1238 chat_request=chat_request,
1239 )
1240 assert state.output_chunk_lens == [3]
1241
1242 chunk, _ = await parsed_derenderer.derender_chat_stream(
1243 model=MODEL_NAME,
1244 generate_chunk=_make_stream_chunk(
1245 [_FakeParser.CONTENT], finish_reason="stop"
1246 ),
1247 state=state,
1248 chat_request=chat_request,
1249 )
1250 assert chunk.choices[0].delta.content == "2"
1251
1252 @pytest.mark.asyncio
1253 async def test_tool_call_id_pinned_across_chunks(self, parsed_derenderer):
1254 """Once an index's ID is recorded in `last_tool_call_ids`, a later
1255 id bearing delta for that same index is pinned to the recorded
1256 value rather than a freshly (re-)generated one."""
1257 chat_request = _chat_request(
1258 tools=[{"type": "function", "function": {"name": "get_weather"}}],
1259 tool_choice="auto",
1260 )
1261 chunk1, state = await parsed_derenderer.derender_chat_stream(
1262 model=MODEL_NAME,
1263 generate_chunk=_make_stream_chunk([_FakeParser.TOOL_START]),
1264 chat_request=chat_request,
1265 )
1266 first_id = chunk1.choices[0].delta.tool_calls[0].id
1267 assert first_id is not None
1268 assert state.last_tool_call_ids == [first_id]
1269
1270 chunk2, state = await parsed_derenderer.derender_chat_stream(
1271 model=MODEL_NAME,
1272 generate_chunk=_make_stream_chunk(
1273 [_FakeParser.TOOL_START], finish_reason="stop"
1274 ),
1275 state=state,
1276 chat_request=chat_request,
1277 )
1278 assert chunk2.choices[0].delta.tool_calls[0].id == first_id
1279 assert state.last_tool_call_ids == [first_id]
1280
1281 @pytest.mark.asyncio
1282 async def test_finish_reason_rewritten_to_tool_calls(self, parsed_derenderer):
1283 chat_request = _chat_request(
1284 tools=[{"type": "function", "function": {"name": "get_weather"}}],
1285 tool_choice="auto",
1286 )
1287 chunk, _ = await parsed_derenderer.derender_chat_stream(
1288 model=MODEL_NAME,
1289 generate_chunk=_make_stream_chunk(
1290 [_FakeParser.TOOL_START, _FakeParser.TOOL_ARG], finish_reason="stop"
1291 ),
1292 chat_request=chat_request,
1293 )
1294 assert chunk.choices[0].finish_reason == "tool_calls"
1295
1296 @pytest.mark.asyncio
1297 async def test_finish_reason_stop_for_named_tool_choice(self, parsed_derenderer):
1298 # ChatCompletionRequest's `check_tool_usage` is a mode="before"
1299 # validator, so it sees the raw value and only accepts "auto",
1300 # "required" or a dict, never an already built
1301 # ChatCompletionNamedToolChoiceParam.
1302 chat_request = _chat_request(
1303 tools=[{"type": "function", "function": {"name": "get_weather"}}],
1304 tool_choice={"type": "function", "function": {"name": "get_weather"}},
1305 )
1306 chunk, _ = await parsed_derenderer.derender_chat_stream(
1307 model=MODEL_NAME,
1308 generate_chunk=_make_stream_chunk(
1309 [_FakeParser.TOOL_START, _FakeParser.TOOL_ARG], finish_reason="stop"
1310 ),
1311 chat_request=chat_request,
1312 )
1313 assert chunk.choices[0].finish_reason == "stop"
1314
1315 @pytest.mark.asyncio
1316 async def test_include_reasoning_false_suppresses_reasoning(
1317 self, parsed_derenderer
1318 ):
1319 chat_request = _chat_request(include_reasoning=False)
1320 chunk, _ = await parsed_derenderer.derender_chat_stream(
1321 model=MODEL_NAME,
1322 generate_chunk=_make_stream_chunk(
1323 [_FakeParser.REASON, _FakeParser.CONTENT], finish_reason="stop"
1324 ),
1325 chat_request=chat_request,
1326 )
1327 delta = chunk.choices[0].delta
1328 assert delta.reasoning is None
1329 assert delta.content == "c"
1330
1331
1332 # ---------------------------------------------------------------------------
1333 # Harmony / GPT-OSS replay — unit, no server
1334 # ---------------------------------------------------------------------------
1335
1336 HARMONY_MODEL = "openai/gpt-oss-20b"
1337 HARMONY_REASONING = "The user wants 2 plus 3."
1338 HARMONY_ANSWER = "The answer is 5."
1339 HARMONY_PROMPT = "<|start|>user<|message|>Add 2 and 3.<|end|><|start|>assistant"
1340 HARMONY_OUTPUT = (
1341 f"<|channel|>analysis<|message|>{HARMONY_REASONING}<|end|>"
1342 f"<|start|>assistant<|channel|>final<|message|>{HARMONY_ANSWER}<|return|>"
1343 )
1344
1345
1346 @pytest.fixture(scope="module")
1347 def harmony_encode():
1348 """Encoder for canned GPT-OSS harmony token sequences."""
1349 pytest.importorskip("openai_harmony")
1350
1351 # Pre-caches the o200k_base BPE file that openai-harmony's Rust backend
1352 # downloads on first use, same as the sibling suite's E2E harmony tests.
1353 from tests.entrypoints.scale_out.derender.test_derender import (
1354 _ensure_harmony_vocab,
1355 )
1356 from vllm.entrypoints.openai.parser.harmony_utils import get_encoding
1357
1358 _ensure_harmony_vocab()
1359 encoding = get_encoding()
1360
1361 def _encode(harmony_str: str) -> list[int]:
1362 return encoding.encode(harmony_str, allowed_special="all")
1363
1364 return _encode
1365
1366
1367 @pytest.fixture(scope="module")
1368 def harmony_tokenizer():
1369 pytest.importorskip("openai_harmony")
1370 return get_tokenizer(HARMONY_MODEL, trust_remote_code=True)
1371
1372
1373 @pytest.fixture(scope="module")
1374 def harmony_derenderer(harmony_tokenizer):
1375 """OnlineDerenderer whose parser resolves to the real `HarmonyParser`.
1376
1377 Same shape as `parsed_derenderer` (mocked renderer, real executor) but
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 """
1381 from vllm.parser.harmony import HarmonyParser
1382
1383 renderer = MagicMock()
1384 renderer.get_tokenizer.return_value = harmony_tokenizer
1385 renderer._executor = ThreadPoolExecutor(max_workers=2)
1386
1387 model_config = MagicMock()
1388 model_config.model = HARMONY_MODEL
1389 model_config.hf_config.model_type = "gpt_oss"
1390 model_config.hf_text_config.model_type = "gpt_oss"
1391 model_config.hf_overrides = None
1392
1393 dr = OnlineDerenderer(
1394 model_config=model_config,
1395 renderer=renderer,
1396 request_logger=None,
1397 chat_template=None,
1398 chat_template_content_format="string",
1399 enable_auto_tools=True,
1400 tool_parser="openai",
1401 reasoning_parser="openai_gptoss",
1402 )
1403 assert dr.use_harmony
1404 assert dr.parser is HarmonyParser
1405 return dr
1406
1407
1408 async def _stream_harmony_deltas(
1409 derenderer,
1410 chat_request,
1411 output_ids: list[int],
1412 prompt_ids: list[int],
1413 chunk_size: int,
1414 ) -> list[DeltaMessage]:
1415 """Drive `output_ids` through the parser path in fixed size chunks.
1416
1417 Returns the per chunk `DeltaMessage`s in order so callers can assert on
1418 intermediate emissions, not just the assembled result.
1419 """
1420 deltas: list[DeltaMessage] = []
1421 state = None
1422 for start in range(0, len(output_ids), chunk_size):
1423 tids = output_ids[start : start + chunk_size]
1424 is_last = start + chunk_size >= len(output_ids)
1425 chunk, state = await derenderer.derender_chat_stream(
1426 model=HARMONY_MODEL,
1427 generate_chunk=_make_stream_chunk(
1428 tids, finish_reason="stop" if is_last else None
1429 ),
1430 state=state,
1431 chat_request=chat_request,
1432 prompt_token_ids=prompt_ids,
1433 )
1434 deltas.append(chunk.choices[0].delta)
1435 return deltas
1436
1437
1438 class TestDerenderChatStreamHarmony:
1439 """derender_chat_stream: replay + `parse_delta` against real HarmonyParser.
1440
1441 Skipped where `openai_harmony` is not installed. `prompt_token_ids` is
1442 threaded through for call shape fidelity only, since HarmonyParser
1443 derives its state from the output tokens alone.
1444 """
1445
1446 @pytest.mark.asyncio
1447 async def test_reasoning_never_leaks_as_content_midstream(
1448 self, harmony_derenderer, harmony_encode
1449 ):
1450 """Regression guard for the RFC's original replay + diff design.
1451
1452 `HarmonyParser.parse()` always flushes to EOS. Mid-stream that
1453 raises `HarmonyError` and the recovery branch re-emits the in
1454 flight message on the `final` channel, so partial analysis surfaces
1455 as content. Replay + `parse_delta` must never do that. Driven one
1456 token per chunk, the finest granularity a client can produce.
1457 """
1458 output_ids = harmony_encode(HARMONY_OUTPUT)
1459 prompt_ids = harmony_encode(HARMONY_PROMPT)
1460 chat_request = _chat_request(model=HARMONY_MODEL, include_reasoning=True)
1461
1462 deltas = await _stream_harmony_deltas(
1463 harmony_derenderer, chat_request, output_ids, prompt_ids, chunk_size=1
1464 )
1465
1466 reasoning = ""
1467 content = ""
1468 for i, delta in enumerate(deltas):
1469 reasoning += delta.reasoning or ""
1470 content += delta.content or ""
1471 # Any analysis text emitted as content breaks the prefix
1472 # property, since the two channels share no prefix here.
1473 assert HARMONY_ANSWER.startswith(content), (
1474 f"content after chunk {i} is not a prefix of the final "
1475 f"channel text: {content!r}"
1476 )
1477 assert HARMONY_REASONING.startswith(reasoning)
1478 assert not delta.tool_calls
1479
1480 assert reasoning == HARMONY_REASONING
1481 assert content == HARMONY_ANSWER
1482
1483 @pytest.mark.asyncio
1484 async def test_stream_matches_batch(self, harmony_derenderer, harmony_encode):
1485 """Streamed assembly equals one shot `/derender` over the same IDs."""
1486 output_ids = harmony_encode(HARMONY_OUTPUT)
1487 prompt_ids = harmony_encode(HARMONY_PROMPT)
1488 chat_request = _chat_request(model=HARMONY_MODEL, include_reasoning=True)
1489
1490 deltas = await _stream_harmony_deltas(
1491 harmony_derenderer, chat_request, output_ids, prompt_ids, chunk_size=3
1492 )
1493 reasoning = "".join(d.reasoning or "" for d in deltas)
1494 content = "".join(d.content or "" for d in deltas)
1495
1496 batch_choices = await harmony_derenderer.derender_chat(
1497 GenerateResponse(
1498 request_id="test-harmony-batch",
1499 choices=[
1500 GenerateResponseChoice(
1501 index=0, token_ids=output_ids, finish_reason="stop"
1502 )
1503 ],
1504 ),
1505 chat_request,
1506 )
1507 message = batch_choices[0].message
1508
1509 assert reasoning == message.reasoning
1510 assert content == message.content
1511
1512
1513 class TestDerenderStreamStateValidation:
1514 """DerenderStreamState rejects malformed caller supplied offsets/lengths."""
1515
1516 def test_negative_prefix_offset_rejected(self):
1517 with pytest.raises(ValidationError):
1518 DerenderStreamState(prefix_offset=-1)
1519
1520 def test_negative_read_offset_rejected(self):
1521 with pytest.raises(ValidationError):
1522 DerenderStreamState(read_offset=-1)
1523
1524 def test_prev_tokens_over_cap_rejected(self):
1525 with pytest.raises(ValidationError):
1526 DerenderStreamState(prev_tokens=["a"] * 1025)
1527
1528 def test_prev_tokens_at_cap_accepted(self):
1529 state = DerenderStreamState(prev_tokens=["a"] * 1024)
1530 assert len(state.prev_tokens) == 1024
1531
1532 def test_output_chunk_lens_mismatch_rejected(self):
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):
1537 with pytest.raises(ValidationError):
1538 DerenderStreamState(output_token_ids=[1, 2], output_chunk_lens=[0, 2])
1539
1540 def test_output_chunk_lens_matching_accepted(self):
1541 state = DerenderStreamState(
1542 output_token_ids=[1, 2, 3], output_chunk_lens=[1, 2]
1543 )
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):
1581 models = MagicMock()
1582 models.is_base_model.return_value = True
1583 models.model_config = MagicMock()
1584 models.model_config.max_model_len = 100_000
1585
1586 online_derenderer = MagicMock()
1587 online_derenderer.parser = None
1588 online_derenderer.derender_completion_stream = AsyncMock(
1589 side_effect=side_effect
1590 )
1591 online_derenderer.derender_chat_stream = AsyncMock(side_effect=side_effect)
1592
1593 return ServingDerender(models=models, online_derenderer=online_derenderer)
1594
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):
1598 serving = self._make_serving(exc)
1599 request = DerenderCompletionStreamRequest(
1600 stream=True,
1601 model=MODEL_NAME,
1602 generate_chunk=_make_stream_chunk([1, 2]),
1603 stream_state=DerenderStreamState(),
1604 )
1605 result = await serving.derender_completion_stream_response(request)
1606 assert isinstance(result, ErrorResponse)
1607 assert result.error.code == 400
1608
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):
1612 serving = self._make_serving(exc)
1613 request = DerenderChatStreamRequest(
1614 stream=True,
1615 model=MODEL_NAME,
1616 generate_chunk=_make_stream_chunk([1, 2]),
1617 stream_state=DerenderStreamState(),
1618 )
1619 result = await serving.derender_chat_stream_response(request)
1620 assert isinstance(result, ErrorResponse)
1621 assert result.error.code == 400
1622
1623
1624 class TestServingDerenderStreamValidation:
1625 """If checks cannot run in `derender_chat_stream_response`: all must
1626 reject with 400 before ever calling into `online_derenderer` (i.e.
1627 before touching the tokenizer)."""
1628
1629 def _make_serving(self, *, parser_configured: bool, max_model_len: int = 100_000):
1630 models = MagicMock()
1631 models.is_base_model.return_value = True
1632 models.model_config = MagicMock()
1633 models.model_config.max_model_len = max_model_len
1634
1635 online_derenderer = MagicMock()
1636 online_derenderer.parser = MagicMock() if parser_configured else None
1637 online_derenderer.derender_chat_stream = AsyncMock(
1638 return_value=(
1639 ChatCompletionStreamResponse(id="t", model=MODEL_NAME, choices=[]),
1640 DerenderStreamState(),
1641 )
1642 )
1643 return ServingDerender(models=models, online_derenderer=online_derenderer)
1644
1645 @pytest.mark.asyncio
1646 async def test_missing_chat_request_with_parser_rejected(self):
1647 serving = self._make_serving(parser_configured=True)
1648 request = DerenderChatStreamRequest(
1649 stream=True,
1650 model=MODEL_NAME,
1651 generate_chunk=_make_stream_chunk([1, 2]),
1652 )
1653 result = await serving.derender_chat_stream_response(request)
1654 assert isinstance(result, ErrorResponse)
1655 assert result.error.code == 400
1656 assert "chat_request" in result.error.message
1657 serving.online_derenderer.derender_chat_stream.assert_not_called()
1658
1659 @pytest.mark.asyncio
1660 async def test_missing_chat_request_without_parser_ok(self):
1661 serving = self._make_serving(parser_configured=False)
1662 request = DerenderChatStreamRequest(
1663 stream=True,
1664 model=MODEL_NAME,
1665 generate_chunk=_make_stream_chunk([1, 2]),
1666 )
1667 result = await serving.derender_chat_stream_response(request)
1668 assert not isinstance(result, ErrorResponse)
1669
1670 @pytest.mark.asyncio
1671 async def test_missing_prompt_token_ids_with_parser_rejected(self):
1672 """A parser configured model must reject a missing prompt_token_ids
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."""
1676 serving = self._make_serving(parser_configured=True)
1677 request = DerenderChatStreamRequest(
1678 stream=True,
1679 model=MODEL_NAME,
1680 generate_chunk=_make_stream_chunk([1, 2]),
1681 chat_request=_chat_request(),
1682 )
1683 result = await serving.derender_chat_stream_response(request)
1684 assert isinstance(result, ErrorResponse)
1685 assert result.error.code == 400
1686 assert "prompt_token_ids" in result.error.message
1687 serving.online_derenderer.derender_chat_stream.assert_not_called()
1688
1689 @pytest.mark.asyncio
1690 async def test_prompt_token_ids_present_with_parser_ok(self):
1691 serving = self._make_serving(parser_configured=True)
1692 request = DerenderChatStreamRequest(
1693 stream=True,
1694 model=MODEL_NAME,
1695 generate_chunk=_make_stream_chunk([1, 2]),
1696 chat_request=_chat_request(),
1697 prompt_token_ids=[1, 2, 3],
1698 )
1699 result = await serving.derender_chat_stream_response(request)
1700 assert not isinstance(result, ErrorResponse)
1701
1702 @pytest.mark.asyncio
1703 async def test_oversized_output_token_ids_rejected(self):
1704 serving = self._make_serving(parser_configured=False, max_model_len=4)
1705 request = DerenderChatStreamRequest(
1706 stream=True,
1707 model=MODEL_NAME,
1708 generate_chunk=_make_stream_chunk([1, 2, 3]),
1709 stream_state=DerenderStreamState(
1710 output_token_ids=[1, 2], output_chunk_lens=[2]
1711 ),
1712 )
1713 result = await serving.derender_chat_stream_response(request)
1714 assert isinstance(result, ErrorResponse)
1715 assert result.error.code == 400
1716 assert "max_model_len" in result.error.message
1717 serving.online_derenderer.derender_chat_stream.assert_not_called()
1718
1719 @pytest.mark.asyncio
1720 async def test_oversized_prompt_token_ids_rejected(self):
1721 """prompt_token_ids is caller supplied and otherwise unbounded. A
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."""
1725 serving = self._make_serving(parser_configured=True, max_model_len=4)
1726 request = DerenderChatStreamRequest(
1727 stream=True,
1728 model=MODEL_NAME,
1729 generate_chunk=_make_stream_chunk([1]),
1730 chat_request=_chat_request(),
1731 prompt_token_ids=[1, 2, 3, 4, 5],
1732 )
1733 result = await serving.derender_chat_stream_response(request)
1734 assert isinstance(result, ErrorResponse)
1735 assert result.error.code == 400
1736 assert "max_model_len" in result.error.message
1737 serving.online_derenderer.derender_chat_stream.assert_not_called()
1738
1739 @pytest.mark.asyncio
1740 async def test_too_many_choices_rejected(self):
1741 """Each streamed chunk contains at most one choice (a single
1742 DerenderStreamState is threaded through every choice). The check
1743 could never fire since derender_chat_stream itself rejects anything
1744 above 1 first."""
1745 serving = self._make_serving(parser_configured=False)
1746 two_choices = GenerateStreamResponse(
1747 request_id="t",
1748 choices=[
1749 GenerateResponseStreamChoice(index=i, token_ids=[1]) for i in range(2)
1750 ],
1751 )
1752 request = DerenderChatStreamRequest(
1753 stream=True, model=MODEL_NAME, generate_chunk=two_choices
1754 )
1755 result = await serving.derender_chat_stream_response(request)
1756 assert isinstance(result, ErrorResponse)
1757 assert result.error.code == 400
1758 assert "at most one choice" in result.error.message
1759 serving.online_derenderer.derender_chat_stream.assert_not_called()
1760
1761
1762 # ---------------------------------------------------------------------------
1763 # Integration tests — require a live render server
1764 # ---------------------------------------------------------------------------
1765
1766
1767 @pytest.fixture(scope="module")
1768 def server():
1769 with RemoteLaunchRenderServer(MODEL_NAME, []) as remote_server:
1770 yield remote_server
1771
1772
1773 @pytest_asyncio.fixture
1774 async def client(server):
1775 async with httpx.AsyncClient(
1776 base_url=server.url_for(""), timeout=30.0
1777 ) as http_client:
1778 yield http_client
1779
1780
1781 async def _render_chat(client) -> dict:
1782 """Render a minimal chat request and return the GenerateRequest dict."""
1783 resp = await client.post(
1784 "/v1/chat/completions/render",
1785 json={
1786 "model": MODEL_NAME,
1787 "messages": [{"role": "user", "content": "Hello"}],
1788 },
1789 )
1790 assert resp.status_code == 200
1791 return resp.json()
1792
1793
1794 @pytest.mark.asyncio
1795 async def test_streaming_completion_derender_roundtrip(client):
1796 """Streaming completions derender: chunked text == non streaming text."""
1797 gen_req = await _render_chat(client)
1798 token_ids: list[int] = gen_req["token_ids"][:8]
1799 mid = len(token_ids) // 2
1800 chunk1_ids, chunk2_ids = token_ids[:mid], token_ids[mid:]
1801
1802 # Non streaming baseline.
1803 non_stream_resp = await client.post(
1804 "/v1/completions/derender",
1805 json={
1806 "model": MODEL_NAME,
1807 "generate_responses": [
1808 {
1809 "request_id": "test-ns",
1810 "choices": [
1811 {
1812 "index": 0,
1813 "token_ids": token_ids,
1814 "finish_reason": "stop",
1815 }
1816 ],
1817 }
1818 ],
1819 },
1820 )
1821 assert non_stream_resp.status_code == 200
1822 expected_text = non_stream_resp.json()["choices"][0]["text"]
1823
1824 # Streaming call 1.
1825 r1 = await client.post(
1826 "/v1/completions/derender",
1827 json={
1828 "stream": True,
1829 "model": MODEL_NAME,
1830 "generate_chunk": {
1831 "request_id": "test-s",
1832 "choices": [
1833 {"index": 0, "token_ids": chunk1_ids, "finish_reason": None}
1834 ],
1835 },
1836 "stream_state": None,
1837 },
1838 )
1839 assert r1.status_code == 200
1840 d1 = r1.json()
1841 text1 = d1["chunk"]["choices"][0]["text"]
1842 state1 = d1["stream_state"]
1843
1844 # Streaming call 2 (final chunk).
1845 r2 = await client.post(
1846 "/v1/completions/derender",
1847 json={
1848 "stream": True,
1849 "model": MODEL_NAME,
1850 "generate_chunk": {
1851 "request_id": "test-s",
1852 "choices": [
1853 {"index": 0, "token_ids": chunk2_ids, "finish_reason": "stop"}
1854 ],
1855 },
1856 "stream_state": state1,
1857 },
1858 )
1859 assert r2.status_code == 200
1860 text2 = r2.json()["chunk"]["choices"][0]["text"]
1861
1862 assert text1 + text2 == expected_text
1863
1864
1865 @pytest.mark.asyncio
1866 async def test_streaming_chat_derender_roundtrip(client):
1867 """Streaming chat derender (plain detok): chunked text == non streaming text."""
1868 gen_req = await _render_chat(client)
1869 token_ids: list[int] = gen_req["token_ids"][:8]
1870 mid = len(token_ids) // 2
1871 chunk1_ids, chunk2_ids = token_ids[:mid], token_ids[mid:]
1872
1873 # Non streaming baseline.
1874 ns = await client.post(
1875 "/v1/chat/completions/derender",
1876 json={
1877 "model": MODEL_NAME,
1878 "generate_response": {
1879 "request_id": "test-ns",
1880 "choices": [
1881 {
1882 "index": 0,
1883 "token_ids": token_ids,
1884 "finish_reason": "stop",
1885 }
1886 ],
1887 },
1888 },
1889 )
1890 assert ns.status_code == 200
1891 expected_content = ns.json()["choices"][0]["message"]["content"]
1892
1893 # Streaming call 1.
1894 r1 = await client.post(
1895 "/v1/chat/completions/derender",
1896 json={
1897 "stream": True,
1898 "model": MODEL_NAME,
1899 "generate_chunk": {
1900 "request_id": "test-s",
1901 "choices": [
1902 {"index": 0, "token_ids": chunk1_ids, "finish_reason": None}
1903 ],
1904 },
1905 "stream_state": None,
1906 },
1907 )
1908 assert r1.status_code == 200
1909 d1 = r1.json()
1910 text1 = d1["chunk"]["choices"][0]["delta"].get("content") or ""
1911 state1 = d1["stream_state"]
1912 # role=assistant on the first chunk
1913 assert d1["chunk"]["choices"][0]["delta"].get("role") == "assistant"
1914
1915 # Streaming call 2.
1916 r2 = await client.post(
1917 "/v1/chat/completions/derender",
1918 json={
1919 "stream": True,
1920 "model": MODEL_NAME,
1921 "generate_chunk": {
1922 "request_id": "test-s",
1923 "choices": [
1924 {"index": 0, "token_ids": chunk2_ids, "finish_reason": "stop"}
1925 ],
1926 },
1927 "stream_state": state1,
1928 },
1929 )
1930 assert r2.status_code == 200
1931 d2 = r2.json()
1932 text2 = d2["chunk"]["choices"][0]["delta"].get("content") or ""
1933 # role must NOT be repeated on subsequent chunks
1934 assert d2["chunk"]["choices"][0]["delta"].get("role") is None
1935
1936 assert text1 + text2 == expected_content
1937
1938
1939 @pytest.mark.asyncio
1940 async def test_streaming_derender_invalid_body_returns_400(client):
1941 """Missing required field in streaming request returns 400."""
1942 r = await client.post(
1943 "/v1/completions/derender",
1944 json={
1945 "stream": True,
1946 # missing required 'model' and 'generate_chunk'
1947 },
1948 )
1949 assert r.status_code == 400
1950
1951
1952 @pytest.mark.asyncio
1953 async def test_streaming_derender_non_object_body_returns_400(client):
1954 """A non object JSON body (e.g. a list) returns 400, not a 500."""
1955 r = await client.post(
1956 "/v1/completions/derender",
1957 json=[1, 2, 3],
1958 )
1959 assert r.status_code == 400
1960
1961
1962 @pytest.mark.asyncio
1963 async def test_streaming_usage_chunk(client):
1964 """Usage only final chunk is forwarded with correct token counts."""
1965 gen_req = await _render_chat(client)
1966 token_ids: list[int] = gen_req["token_ids"][:6]
1967 state: dict = {}
1968
1969 # Send content chunk first.
1970 r1 = await client.post(
1971 "/v1/completions/derender",
1972 json={
1973 "stream": True,
1974 "model": MODEL_NAME,
1975 "generate_chunk": {
1976 "request_id": "usage-test",
1977 "choices": [
1978 {"index": 0, "token_ids": token_ids, "finish_reason": "stop"}
1979 ],
1980 },
1981 "stream_state": None,
1982 },
1983 )
1984 assert r1.status_code == 200
1985 state = r1.json()["stream_state"]
1986
1987 # Send usage only final chunk.
1988 r2 = await client.post(
1989 "/v1/completions/derender",
1990 json={
1991 "stream": True,
1992 "model": MODEL_NAME,
1993 "generate_chunk": {
1994 "request_id": "usage-test",
1995 "choices": [],
1996 "usage": {
1997 "prompt_tokens": 10,
1998 "completion_tokens": len(token_ids),
1999 "total_tokens": 10 + len(token_ids),
2000 },
2001 },
2002 "stream_state": state,
2003 "prompt_tokens": 10,
2004 },
2005 )
2006 assert r2.status_code == 200
2007 d2 = r2.json()
2008 assert d2["chunk"]["choices"] == []
2009 assert d2["chunk"]["usage"]["prompt_tokens"] == 10
2010 assert d2["chunk"]["usage"]["completion_tokens"] == len(token_ids)
2011
2012
2013 # ---------------------------------------------------------------------------
2014 # Integration tests for parser configured (reasoning + tool calls) require a
2015 # live render server. Mirrors the parser_server / parser_tokenizer pattern
2016 # from test_derender.py.
2017 # ---------------------------------------------------------------------------
2018
2019 PARSER_MODEL = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
2020
2021 _PARSER_TOOLS = [
2022 {
2023 "type": "function",
2024 "function": {
2025 "name": "get_weather",
2026 "description": "Get weather for a city",
2027 "parameters": {
2028 "type": "object",
2029 "properties": {"city": {"type": "string"}},
2030 },
2031 },
2032 }
2033 ]
2034
2035
2036 @pytest.fixture(scope="module")
2037 def parser_server():
2038 args = [
2039 "--enable-auto-tool-choice",
2040 "--tool-call-parser",
2041 "hermes",
2042 "--reasoning-parser",
2043 "deepseek_r1",
2044 ]
2045 with RemoteLaunchRenderServer(PARSER_MODEL, args) as remote_server:
2046 yield remote_server
2047
2048
2049 @pytest_asyncio.fixture
2050 async def parser_client(parser_server):
2051 async with httpx.AsyncClient(
2052 base_url=parser_server.url_for(""), timeout=60.0
2053 ) as http_client:
2054 yield http_client
2055
2056
2057 @pytest.fixture(scope="module")
2058 def parser_tokenizer():
2059 return get_tokenizer(PARSER_MODEL)
2060
2061
2062 def _require_parser_markers(tokenizer, text: str, *markers: str) -> list[int]:
2063 """Encode text and skip the test if any marker is lost in roundtrip."""
2064 ids = tokenizer.encode(text, add_special_tokens=False)
2065 decoded = tokenizer.decode(ids, skip_special_tokens=False)
2066 for m in markers:
2067 if m not in decoded:
2068 pytest.skip(f"Marker {m!r} lost in encode->decode roundtrip")
2069 return ids
2070
2071
2072 async def _render_parser_chat(client, messages: list[dict]) -> dict:
2073 resp = await client.post(
2074 "/v1/chat/completions/render",
2075 json={"model": PARSER_MODEL, "messages": messages},
2076 )
2077 assert resp.status_code == 200, resp.text
2078 return resp.json()
2079
2080
2081 @pytest.mark.asyncio
2082 @pytest.mark.parametrize(
2083 "chunking",
2084 [
2085 "whole",
2086 "per_token",
2087 pytest.param(
2088 "triples",
2089 marks=pytest.mark.xfail(
2090 reason="<think> leaks into reasoning when it shares a "
2091 "multi-token delta with reasoning text (vllm#55195)"
2092 ),
2093 ),
2094 ],
2095 )
2096 async def test_stream_parsed_matches_batch_reasoning(
2097 parser_client, parser_tokenizer, chunking
2098 ):
2099 """Streamed == batch: assembled reasoning/content equal the non
2100 streaming `/derender` result over the same token IDs, for every
2101 chunking of the same output (chunking invariance)."""
2102 messages = [{"role": "user", "content": "What is 2+3?"}]
2103 gen_req = await _render_parser_chat(parser_client, messages)
2104
2105 reasoning_text = "The user wants 2 plus 3. That is 5."
2106 answer_text = "The answer is 5."
2107 output_text = f"<think>{reasoning_text}</think>{answer_text}"
2108 output_ids = _require_parser_markers(parser_tokenizer, output_text, "</think>")
2109
2110 chat_request = {
2111 "model": PARSER_MODEL,
2112 "messages": messages,
2113 "include_reasoning": True,
2114 }
2115
2116 batch_resp = await parser_client.post(
2117 "/v1/chat/completions/derender",
2118 json={
2119 "model": PARSER_MODEL,
2120 "generate_response": {
2121 "request_id": "batch",
2122 "choices": [
2123 {"index": 0, "token_ids": output_ids, "finish_reason": "stop"}
2124 ],
2125 },
2126 "prompt_tokens": len(gen_req["token_ids"]),
2127 "chat_request": chat_request,
2128 },
2129 )
2130 assert batch_resp.status_code == 200, batch_resp.text
2131 batch_msg = batch_resp.json()["choices"][0]["message"]
2132
2133 n = len(output_ids)
2134 chunk_sizes = {
2135 "whole": [n],
2136 "per_token": [1] * n,
2137 "triples": [3] * (n // 3) + [n % 3],
2138 }[chunking]
2139 chunk_sizes = [c for c in chunk_sizes if c > 0]
2140 streamed = await stream_chat_derender(
2141 parser_client,
2142 output_ids,
2143 chunk_sizes,
2144 chat_request,
2145 len(gen_req["token_ids"]),
2146 gen_req["token_ids"],
2147 )
2148 assert streamed["content"] == batch_msg["content"]
2149 assert streamed["reasoning"] == batch_msg.get("reasoning")
2150
2151
2152 @pytest.mark.asyncio
2153 async def test_stream_parsed_matches_batch_tool_call(parser_client, parser_tokenizer):
2154 """Tool call name+id+arguments parity between streamed and batch, plus
2155 the finish_reason -> "tool_calls" rewrite for auto tool choice."""
2156 messages = [{"role": "user", "content": "Weather in Paris?"}]
2157 gen_req = await _render_parser_chat(parser_client, messages)
2158
2159 output_text = (
2160 "<think>Let me check.</think>"
2161 '<tool_call>\n{"name": "get_weather", '
2162 '"arguments": {"city": "Paris"}}\n</tool_call>'
2163 )
2164 output_ids = _require_parser_markers(
2165 parser_tokenizer, output_text, "</think>", "<tool_call>", "</tool_call>"
2166 )
2167 chat_request = {
2168 "model": PARSER_MODEL,
2169 "messages": messages,
2170 "tools": _PARSER_TOOLS,
2171 "tool_choice": "auto",
2172 }
2173
2174 batch_resp = await parser_client.post(
2175 "/v1/chat/completions/derender",
2176 json={
2177 "model": PARSER_MODEL,
2178 "generate_response": {
2179 "request_id": "batch",
2180 "choices": [
2181 {"index": 0, "token_ids": output_ids, "finish_reason": "stop"}
2182 ],
2183 },
2184 "prompt_tokens": len(gen_req["token_ids"]),
2185 "chat_request": chat_request,
2186 },
2187 )
2188 assert batch_resp.status_code == 200, batch_resp.text
2189 batch_choice = batch_resp.json()["choices"][0]
2190 if not batch_choice["message"]["tool_calls"]:
2191 pytest.skip("Model did not emit a tool call")
2192
2193 for chunk_sizes in ([len(output_ids)], [1] * len(output_ids)):
2194 # Snapshot of tool_calls[0]["arguments"] after every chunk, to check
2195 # it only ever grows by appending. It never retracts or duplicates
2196 # already streamed text. That's the exact failure mode the
2197 # replay + parse_delta design (over diff-based streaming) exists to
2198 # avoid. Parity with the batch result alone wouldn't catch a
2199 # transient mid-stream regression.
2200 arg_snapshots: list[str] = []
2201
2202 def _record(
2203 tool_calls: list[dict], _snapshots: list[str] = arg_snapshots
2204 ) -> None:
2205 if tool_calls and tool_calls[0]["arguments"]:
2206 _snapshots.append(tool_calls[0]["arguments"])
2207
2208 streamed = await stream_chat_derender(
2209 parser_client,
2210 output_ids,
2211 chunk_sizes,
2212 chat_request,
2213 len(gen_req["token_ids"]),
2214 gen_req["token_ids"],
2215 on_chunk=_record,
2216 )
2217 assert streamed["tool_calls"]
2218 assert streamed["tool_calls"][0]["name"] == "get_weather"
2219 assert streamed["tool_calls"][0]["id"] is not None
2220 assert json.loads(streamed["tool_calls"][0]["arguments"]) == json.loads(
2221 batch_choice["message"]["tool_calls"][0]["function"]["arguments"]
2222 )
2223 assert streamed["finish_reason"] == "tool_calls"
2224
2225 assert arg_snapshots, "expected at least one tool-call argument delta"
2226 for prev, curr in zip(arg_snapshots, arg_snapshots[1:]):
2227 assert curr.startswith(prev), (
2228 f"tool-call arguments regressed: {prev!r} -> {curr!r}"
2229 )
2230 assert arg_snapshots[-1] == streamed["tool_calls"][0]["arguments"]
2231
2232
2233 @pytest.mark.asyncio
2234 async def test_stream_parsed_cjk_across_chunk_boundaries(
2235 parser_client, parser_tokenizer
2236 ):
2237 """CJK/emoji tokens split across chunk boundaries with a parser active
2238 across the reasoning -> content transition (parser path regression
2239 guard analogous to #46159 in the plain detok path)."""
2240 messages = [{"role": "user", "content": "Reply in Chinese"}]
2241 gen_req = await _render_parser_chat(parser_client, messages)
2242
2243 reasoning_text = "思考中"
2244 answer_text = "你好世界 😀"
2245 output_text = f"<think>{reasoning_text}</think>{answer_text}"
2246 output_ids = _require_parser_markers(parser_tokenizer, output_text, "</think>")
2247
2248 chat_request = {
2249 "model": PARSER_MODEL,
2250 "messages": messages,
2251 "include_reasoning": True,
2252 }
2253 streamed = await stream_chat_derender(
2254 parser_client,
2255 output_ids,
2256 [1] * len(output_ids),
2257 chat_request,
2258 len(gen_req["token_ids"]),
2259 gen_req["token_ids"],
2260 )
2261 assert "�" not in (streamed["content"] or "")
2262 assert "�" not in (streamed["reasoning"] or "")
2263 assert answer_text in (streamed["content"] or "")
2264
2265
2266 @pytest.mark.asyncio
2267 async def test_stream_parsed_include_reasoning_false(parser_client, parser_tokenizer):
2268 """include_reasoning=False emits no reasoning deltas on the streaming
2269 parser path."""
2270 messages = [{"role": "user", "content": "What is 2+3?"}]
2271 gen_req = await _render_parser_chat(parser_client, messages)
2272
2273 output_text = "<think>reasoning here</think>The answer is 5."
2274 output_ids = _require_parser_markers(parser_tokenizer, output_text, "</think>")
2275
2276 chat_request = {
2277 "model": PARSER_MODEL,
2278 "messages": messages,
2279 "include_reasoning": False,
2280 }
2281 streamed = await stream_chat_derender(
2282 parser_client,
2283 output_ids,
2284 [1] * len(output_ids),
2285 chat_request,
2286 len(gen_req["token_ids"]),
2287 gen_req["token_ids"],
2288 )
2289 assert streamed["reasoning"] is None
2290
2291
2292 @pytest.mark.asyncio
2293 async def test_stream_parsed_missing_chat_request_rejected(parser_client):
2294 """Parser configured + no chat_request on the streaming endpoint -> 400
2295 (the live-server counterpart to the mocked
2296 `TestServingDerenderStreamValidation` checks above)."""
2297 resp = await parser_client.post(
2298 "/v1/chat/completions/derender",
2299 json={
2300 "stream": True,
2301 "model": PARSER_MODEL,
2302 "generate_chunk": {
2303 "request_id": "reject-test",
2304 "choices": [{"index": 0, "token_ids": [1, 2], "finish_reason": None}],
2305 },
2306 },
2307 )
2308 assert resp.status_code == 400
2309 assert "chat_request" in resp.json()["error"]["message"]
2310
2311
2312 @pytest.mark.asyncio
2313 async def test_stream_parsed_missing_prompt_token_ids_rejected(parser_client):
2314 """Parser configured + chat_request given but no prompt_token_ids on the
2315 streaming endpoint -> 400 (the live-server counterpart to the mocked
2316 `test_missing_prompt_token_ids_with_parser_rejected` above). Without
2317 prompt_token_ids, parse_delta cannot tell whether the prompt left
2318 reasoning open and would silently misclassify reasoning as content."""
2319 messages = [{"role": "user", "content": "Hello"}]
2320 resp = await parser_client.post(
2321 "/v1/chat/completions/derender",
2322 json={
2323 "stream": True,
2324 "model": PARSER_MODEL,
2325 "generate_chunk": {
2326 "request_id": "reject-test",
2327 "choices": [{"index": 0, "token_ids": [1, 2], "finish_reason": None}],
2328 },
2329 "chat_request": {
2330 "model": PARSER_MODEL,
2331 "messages": messages,
2332 "include_reasoning": True,
2333 },
2334 },
2335 )
2336 assert resp.status_code == 400
2337 assert "prompt_token_ids" in resp.json()["error"]["message"]