Files
vLLM/tests/entrypoints/scale_out/derender/test_derender_stream.py
T

2339 lines
85 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for streaming derender.
Tests are split into two layers:
1. Unit tests (no server): covers `_detokenize_delta` correctness
(chunked == one-shot) and `derender_completion_stream` /
`derender_chat_stream` logic via a real tokenizer on a tiny model.
The parser path is covered both with a deterministic stub parser and
with the real `HarmonyParser` (skipped without `openai_harmony`).
2. Integration tests (require a running render server): covers the full
HTTP round-trip through the streaming endpoint. Marked with
`@pytest.mark.asyncio` and gated by the `server` / `client`
fixtures from the sibling `test_derender.py`.
"""
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import pytest_asyncio
from pydantic import ValidationError
from tokenizers.decoders import DecodeStream
import vllm.renderers.online_derenderer as online_derenderer_module
from tests.entrypoints.scale_out.derender.utils import stream_chat_derender
from tests.utils import RemoteLaunchRenderServer
from vllm.entrypoints.generate.base.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
PerRequestMetrics,
SpeculativeDecodingMetrics,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
ChatCompletionStreamResponse,
)
from vllm.entrypoints.openai.completion.protocol import CompletionRequest
from vllm.entrypoints.scale_out.derender.serving import ServingDerender
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
DerenderChatRequest,
DerenderChatStreamRequest,
DerenderCompletionRequest,
DerenderCompletionStreamRequest,
DerenderStreamState,
GenerateStreamResponse,
GenerateTokensChoice,
GenerateTokensResponse,
GenerateTokensStreamChoice,
GenerateTokensStreamResponse,
)
from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo
from vllm.parser import Parser
from vllm.renderers.online_derenderer import (
OnlineDerenderer,
_decode_params,
_seed_stream_state,
)
from vllm.tokenizers import get_tokenizer
from vllm.utils import random_uuid
MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
def _merge_fake_deltas(
accumulated: DeltaMessage | None, new: DeltaMessage | None
) -> DeltaMessage | None:
"""Fold one token's worth of `_FakeParser` output into a per call
accumulator, the same way a real parser merges multiple tokens'
effects into the single `DeltaMessage` it returns per `parse_delta`
call.
"""
if new is None:
return accumulated
if accumulated is None:
accumulated = DeltaMessage()
if new.content:
accumulated.content = (accumulated.content or "") + new.content
if new.reasoning:
accumulated.reasoning = (accumulated.reasoning or "") + new.reasoning
for tc in new.tool_calls:
starts_new_call = tc.id is not None or (
tc.function is not None and tc.function.name is not None
)
existing = (
None
if starts_new_call
else next((t for t in accumulated.tool_calls if t.index == tc.index), None)
)
if existing is None:
accumulated.tool_calls.append(tc)
continue
if existing.function is None:
existing.function = DeltaFunctionCall()
if tc.function is not None and tc.function.arguments:
existing.function.arguments = (
existing.function.arguments or ""
) + tc.function.arguments
return accumulated
class _FakeParser(Parser):
"""Deterministic `Parser` stub for unit testing the replay/merge/pin
mechanics of `OnlineDerenderer._derender_chat_stream_parsed` in
isolation from any real reasoning/tool parser's markup grammar.
Processes every id in `delta_token_ids` in order and folds their
effects into one `DeltaMessage`, like a real parser handling a
multi-token delta in a single call. Ignores `delta_text`:
- `TOOL_START` opens a new tool call at index 0 (id + name).
- `TOOL_ARG` appends one `"a"` to that tool call's arguments.
- `REASON` emits one `"r"` of reasoning (suppressed when the request
has `include_reasoning=False`, mirroring the real parsers).
- `CONTENT` emits one `"c"` of content.
- An empty `delta_token_ids` with `finished=True` (the finish only
flush call) emits a `"FLUSH"` content sentinel so tests can confirm
it happened.
- Anything else emits nothing.
"""
TOOL_START = 9
TOOL_ARG = 10
REASON = 11
CONTENT = 12
def parse_delta(
self,
delta_text,
delta_token_ids,
request,
prompt_token_ids=None,
*,
finished,
):
if not delta_token_ids:
return DeltaMessage(content="FLUSH") if finished else None
accumulated: DeltaMessage | None = None
for tok in delta_token_ids:
accumulated = _merge_fake_deltas(
accumulated, self._parse_one_token(tok, request)
)
return accumulated
def _parse_one_token(self, tok: int, request) -> DeltaMessage | None:
if tok == self.TOOL_START:
return DeltaMessage(
tool_calls=[
DeltaToolCall(
id=f"call-{random_uuid()}",
type="function",
function=DeltaFunctionCall(name="get_weather", arguments=""),
index=0,
)
]
)
if tok == self.TOOL_ARG:
return DeltaMessage(
tool_calls=[
DeltaToolCall(index=0, function=DeltaFunctionCall(arguments="a"))
]
)
if tok == self.REASON:
if not request.include_reasoning:
return None
return DeltaMessage(reasoning="r")
if tok == self.CONTENT:
return DeltaMessage(content="c")
return None
# ---------------------------------------------------------------------------
# Helpers shared across tests
# ---------------------------------------------------------------------------
def _make_stream_chunk(
token_ids: list[int],
index: int = 0,
finish_reason: str | None = None,
request_id: str = "test-req",
usage: dict | None = None,
metrics: PerRequestMetrics | None = None,
logprobs: dict | None = None,
prompt_token_ids: list[int] | None = None,
) -> GenerateStreamResponse:
"""Build a GenerateStreamResponse SSE chunk."""
return GenerateTokensStreamResponse(
request_id=request_id,
choices=[
GenerateTokensStreamChoice(
index=index,
token_ids=token_ids,
finish_reason=finish_reason,
logprobs=logprobs,
)
],
usage=UsageInfo(**usage) if usage else None,
metrics=metrics,
prompt_token_ids=prompt_token_ids,
)
def _placeholder_logprobs(token_ids: list[int]) -> dict:
"""Per-token logprob entries using token_id:N placeholders, as sent by
the generate worker."""
return {
"content": [
{
"token": f"token_id:{tid}",
"logprob": -0.5,
"bytes": None,
"top_logprobs": [],
}
for tid in token_ids
]
}
def _make_usage_chunk(
completion_tokens: int,
prompt_tokens: int = 0,
request_id: str = "test-req",
) -> GenerateStreamResponse:
"""Build a usage only final SSE chunk (empty choices)."""
return GenerateTokensStreamResponse(
request_id=request_id,
choices=[],
usage=UsageInfo(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
),
)
# ---------------------------------------------------------------------------
# Unit tests — no running server
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def tokenizer():
"""Load the tiny tokenizer used across unit tests."""
return get_tokenizer(MODEL_NAME)
@pytest.fixture(scope="module")
def derenderer(tokenizer, request):
"""Construct a minimal OnlineDerenderer backed by a stub renderer."""
renderer = MagicMock()
renderer.get_tokenizer.return_value = tokenizer
executor = ThreadPoolExecutor(max_workers=1)
request.addfinalizer(executor.shutdown)
renderer._executor = executor
model_config = MagicMock()
model_config.hf_config.model_type = "llama"
model_config.model = MODEL_NAME
return OnlineDerenderer(
model_config=model_config,
renderer=renderer,
request_logger=None,
chat_template=None,
chat_template_content_format="string",
trust_request_chat_template=False,
enable_auto_tools=False,
tool_parser=None,
reasoning_parser=None,
)
@pytest.fixture(scope="module")
def parsed_derenderer(tokenizer):
"""OnlineDerenderer with `_FakeParser` wired in as `self.parser`.
The parser-configured path runs `_derender_chat_stream_parsed` via
`make_async(..., executor=renderer._executor)`, so
`renderer._executor` must be a real `ThreadPoolExecutor`.
`loop.run_in_executor` cannot submit work to a `MagicMock`.
"""
renderer = MagicMock()
renderer.get_tokenizer.return_value = tokenizer
renderer._executor = ThreadPoolExecutor(max_workers=2)
model_config = MagicMock()
model_config.hf_config.model_type = "llama"
model_config.model = MODEL_NAME
dr = OnlineDerenderer(
model_config=model_config,
renderer=renderer,
request_logger=None,
chat_template=None,
chat_template_content_format="string",
)
dr.parser = _FakeParser
return dr
def test_non_streaming_derender_initializes_parser_prefix(
derenderer, tokenizer, monkeypatch
):
from unittest.mock import MagicMock
from vllm.entrypoints.generate.base.protocol import FunctionCall
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
parser = MagicMock()
parser.parse.return_value = (
None,
None,
[FunctionCall(name="tool", arguments="{}")],
)
monkeypatch.setattr(derenderer, "parser", MagicMock(return_value=parser))
generated_ids = tokenizer.encode("answer", add_special_tokens=False)
response = GenerateTokensResponse(
request_id="test",
prompt_token_ids=[11, 12],
choices=[
GenerateTokensChoice(
index=0,
token_ids=generated_ids,
finish_reason="length",
)
],
)
request = ChatCompletionRequest(
model=MODEL_NAME,
messages=[{"role": "user", "content": "question"}],
)
choices = derenderer._derender_chat(response, request)
parser.set_prompt_token_ids.assert_called_once_with([11, 12])
assert choices[0].message.tool_calls[0].function.name == "tool"
assert choices[0].finish_reason == "length"
class TestDetokenizeDelta:
"""_detokenize_delta: chunked decode must equal one shot decode."""
def _one_shot(self, tokenizer, token_ids: list[int]) -> str:
return tokenizer.decode(token_ids, skip_special_tokens=True)
def _chunked(self, derenderer, tokenizer, chunks: list[list[int]]) -> str:
state = DerenderStreamState()
parts: list[str] = []
for delta in chunks:
text, state = derenderer._detokenize_delta(
tokenizer, delta, state, skip_special_tokens=True
)
parts.append(text)
return "".join(parts)
def test_single_chunk(self, derenderer, tokenizer):
"""All tokens in one chunk == one shot decode."""
token_ids = tokenizer.encode("Hello world")[:8]
assert self._chunked(derenderer, tokenizer, [token_ids]) == self._one_shot(
tokenizer, token_ids
)
def test_two_equal_chunks(self, derenderer, tokenizer):
"""Split in half and reassemble == one shot."""
token_ids = tokenizer.encode("Hello world from streaming derender")[:12]
mid = len(token_ids) // 2
chunks = [token_ids[:mid], token_ids[mid:]]
assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
tokenizer, token_ids
)
def test_single_token_per_chunk(self, derenderer, tokenizer):
"""One token per chunk (most granular streaming) == one shot."""
token_ids = tokenizer.encode("incremental detokenization test")[:10]
chunks = [[t] for t in token_ids]
assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
tokenizer, token_ids
)
def test_empty_delta_passthrough(self, derenderer, tokenizer):
"""An empty delta (usage only chunk) emits empty string and preserves state."""
token_ids = tokenizer.encode("Hello")[:4]
_, state = derenderer._detokenize_delta(
tokenizer, token_ids, DerenderStreamState(), skip_special_tokens=True
)
text, new_state = derenderer._detokenize_delta(
tokenizer, [], state, skip_special_tokens=True
)
assert text == ""
assert new_state.prev_tokens == state.prev_tokens
assert new_state.prefix_offset == state.prefix_offset
assert new_state.read_offset == state.read_offset
def test_multibyte_char_split_across_chunks(self, derenderer, tokenizer):
"""A CJK/emoji char straddling chunk boundaries == one shot.
Regression test for held back trailing incomplete UTF-8 byte
sequences being dropped when the rebuild window marks them as
already read (see #46159).
"""
token_ids = tokenizer.encode("Hello ✅ world 日本語")[:16]
chunks = [[t] for t in token_ids]
assert self._chunked(derenderer, tokenizer, chunks) == self._one_shot(
tokenizer, token_ids
)
def test_state_carries_across_calls(self, derenderer, tokenizer):
"""Decode state threads across calls. Text still matches one shot."""
t1 = tokenizer.encode("Hello")[:2]
t2 = tokenizer.encode(" world")[:2]
state = DerenderStreamState()
text1, state = derenderer._detokenize_delta(tokenizer, t1, state)
text2, state = derenderer._detokenize_delta(tokenizer, t2, state)
assert text1 + text2 == self._one_shot(tokenizer, t1 + t2)
# Offsets are rebased to the carried tail each chunk
assert state.prefix_offset == 0
def test_state_window_stays_bounded(self, derenderer, tokenizer):
"""prev_tokens must not grow with the number of chunks (bounded transport).
Guards that the carried decode window is a small constant
tail, so cumulative `stream_state` transport is O(n) and not O(n^2).
"""
token_ids = tokenizer.encode(
"a reasonably long ascii stream of tokens used to exercise the "
"window bound across many single token chunks so the carried "
"state cannot grow linearly with the generation length"
)
assert len(token_ids) > 32
state = DerenderStreamState()
max_window = 0
for tok in token_ids:
_, state = derenderer._detokenize_delta(tokenizer, [tok], state)
max_window = max(max_window, len(state.prev_tokens))
# Bounded by a small constant independent of len(token_ids)
assert max_window <= 32
def test_truncated_multibyte_char_is_held_back(self, derenderer, tokenizer):
"""Output ending mid-character emits no U+FFFD: batch == stream == engine."""
emoji_ids = tokenizer.encode("🌈", add_special_tokens=False)
byte_ids = emoji_ids[-4:]
assert len(byte_ids) == 4
output_ids = byte_ids[:-1]
batch, _ = derenderer._detokenize_delta(
tokenizer, output_ids, DerenderStreamState()
)
streamed = self._chunked(derenderer, tokenizer, [[t] for t in output_ids])
engine = _decode_stream_expected(tokenizer, [], output_ids)
assert "�" not in batch
assert batch == streamed == engine
full, _ = derenderer._detokenize_delta(
tokenizer, byte_ids, DerenderStreamState()
)
assert full.endswith("🌈")
@pytest.mark.parametrize(
("skip", "spaces", "preserve", "fast", "expected"),
[
(True, True, False, True, (True, True)),
(True, False, False, True, (True, True)),
(True, False, False, False, (True, False)),
(False, False, False, True, (False, False)),
(False, False, True, True, (False, False)),
(True, False, True, True, (False, False)),
(True, True, True, True, (False, True)),
],
)
def test_decode_params_mirror_engine(
self, monkeypatch, skip, spaces, preserve, fast, expected
):
"""Flags match the engine's fast or slow detokenizer for the tokenizer."""
monkeypatch.setattr(
online_derenderer_module, "uses_fast_detokenizer", lambda _: fast
)
request = CompletionRequest(
model=MODEL_NAME,
prompt="x",
skip_special_tokens=skip,
spaces_between_special_tokens=spaces,
)
assert _decode_params(None, request, preserve_special=preserve) == expected
def test_decode_params_default_without_request(self, tokenizer):
assert _decode_params(tokenizer, None) == (True, True)
def test_n_independent_streams_same_result(self, derenderer, tokenizer):
"""N parallel streams with the same token sequence give the same text."""
token_ids = tokenizer.encode("parallel streams")[:8]
mid = len(token_ids) // 2
results = []
for _ in range(3):
state = DerenderStreamState()
text, state = derenderer._detokenize_delta(
tokenizer, token_ids[:mid], state
)
text2, _ = derenderer._detokenize_delta(tokenizer, token_ids[mid:], state)
results.append(text + text2)
assert len(set(results)) == 1, "All independent streams must produce same text"
assert results[0] == self._one_shot(tokenizer, token_ids)
# ---------------------------------------------------------------------------
# Prompt seeded detokenization (matches the engine's primed detokenizer)
# ---------------------------------------------------------------------------
def _decode_stream_expected(
tokenizer, prompt_ids: list[int], output_ids: list[int]
) -> str:
"""Engine-equivalent decode via `DecodeStream`, primed by stepping the
prompt (`DecodeStream(ids=...)` needs tokenizers >= 0.22)."""
stream = DecodeStream(skip_special_tokens=True)
for tid in prompt_ids:
stream.step(tokenizer.backend_tokenizer, tid)
return "".join(
s
for tid in output_ids
if (s := stream.step(tokenizer.backend_tokenizer, tid)) is not None
)
@pytest.fixture(scope="module")
def leading_space_ids(tokenizer):
"""(prompt_ids, output_ids, expected) where seeded and unseeded decode
differ by the leading space."""
prompt_ids = tokenizer.encode("[INST] Hi [/INST]", add_special_tokens=False)
output_ids = tokenizer.encode("Hello there, output", add_special_tokens=False)
expected = _decode_stream_expected(tokenizer, prompt_ids, output_ids)
unseeded = tokenizer.decode(output_ids, skip_special_tokens=True)
assert expected.startswith(" ") and not unseeded.startswith(" "), (
"fixture no longer reproduces the leading-space divergence; "
"pick a different prompt/output pair"
)
return prompt_ids, output_ids, expected
class TestSeedStreamState:
"""`_seed_stream_state`: pure helper building the initial decode state."""
def test_seeds_prev_tokens_from_prompt_tail(self, tokenizer, leading_space_ids):
prompt_ids, _, _ = leading_space_ids
state = _seed_stream_state(tokenizer, prompt_ids, skip_special_tokens=True)
assert state.prev_tokens
assert state.read_offset == len(state.prev_tokens)
def test_none_prompt_token_ids_returns_empty_state(self, tokenizer):
assert (
_seed_stream_state(tokenizer, None, skip_special_tokens=True)
== DerenderStreamState()
)
def test_empty_prompt_token_ids_returns_empty_state(self, tokenizer):
assert (
_seed_stream_state(tokenizer, [], skip_special_tokens=True)
== DerenderStreamState()
)
class TestPromptSeededLeadingSpace:
"""Every decode entry point keeps the first token's leading space when
given prompt_token_ids."""
@pytest.mark.asyncio
async def test_chat_batch_seeded_via_explicit_prompt_token_ids(
self, derenderer, leading_space_ids
):
prompt_ids, output_ids, expected = leading_space_ids
choices = await derenderer.derender_chat(
GenerateTokensResponse(
request_id="t",
choices=[
GenerateTokensChoice(
index=0, token_ids=output_ids, finish_reason="stop"
)
],
),
prompt_token_ids=prompt_ids,
)
assert choices[0].message.content == expected
@pytest.mark.asyncio
async def test_chat_batch_seeded_via_generate_response_fallback(
self, derenderer, leading_space_ids
):
prompt_ids, output_ids, expected = leading_space_ids
choices = await derenderer.derender_chat(
GenerateTokensResponse(
request_id="t",
choices=[
GenerateTokensChoice(
index=0, token_ids=output_ids, finish_reason="stop"
)
],
prompt_token_ids=prompt_ids,
),
)
assert choices[0].message.content == expected
@pytest.mark.asyncio
async def test_chat_batch_explicit_takes_precedence_over_fallback(
self, derenderer, tokenizer, leading_space_ids
):
prompt_ids, output_ids, expected = leading_space_ids
wrong_ids = tokenizer.encode("a different prompt", add_special_tokens=False)
choices = await derenderer.derender_chat(
GenerateTokensResponse(
request_id="t",
choices=[
GenerateTokensChoice(
index=0, token_ids=output_ids, finish_reason="stop"
)
],
prompt_token_ids=wrong_ids,
),
prompt_token_ids=prompt_ids,
)
assert choices[0].message.content == expected
@pytest.mark.asyncio
async def test_chat_batch_without_prompt_context_stays_unseeded(
self, derenderer, leading_space_ids
):
_, output_ids, _ = leading_space_ids
choices = await derenderer.derender_chat(
GenerateTokensResponse(
request_id="t",
choices=[
GenerateTokensChoice(
index=0, token_ids=output_ids, finish_reason="stop"
)
],
),
)
assert not choices[0].message.content.startswith(" ")
@pytest.mark.asyncio
async def test_completion_batch_seeded(self, derenderer, leading_space_ids):
prompt_ids, output_ids, expected = leading_space_ids
choices, _, _ = await derenderer.derender_completion(
[
GenerateTokensResponse(
request_id="t",
choices=[
GenerateTokensChoice(
index=0, token_ids=output_ids, finish_reason="stop"
)
],
)
],
prompt_token_ids=[prompt_ids],
)
assert choices[0].text == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("from_generate_chunk", [False, True])
async def test_chat_stream_first_chunk_seeded(
self, derenderer, leading_space_ids, from_generate_chunk
):
prompt_ids, output_ids, expected = leading_space_ids
mid = len(output_ids) // 2
chunk1, state = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
output_ids[:mid],
prompt_token_ids=prompt_ids if from_generate_chunk else None,
),
prompt_token_ids=None if from_generate_chunk else prompt_ids,
)
# Only needed on the first chunk; the carried state already holds
# the seeded window from here on.
chunk2, _ = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(output_ids[mid:], finish_reason="stop"),
state=state,
)
streamed = (chunk1.choices[0].delta.content or "") + (
chunk2.choices[0].delta.content or ""
)
assert streamed == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("from_generate_chunk", [False, True])
async def test_completion_stream_first_chunk_seeded(
self, derenderer, leading_space_ids, from_generate_chunk
):
prompt_ids, output_ids, expected = leading_space_ids
mid = len(output_ids) // 2
chunk1, state = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
output_ids[:mid],
prompt_token_ids=prompt_ids if from_generate_chunk else None,
),
prompt_token_ids=None if from_generate_chunk else prompt_ids,
)
chunk2, _ = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(output_ids[mid:], finish_reason="stop"),
state=state,
)
assert chunk1.choices[0].text + chunk2.choices[0].text == expected
@pytest.mark.asyncio
async def test_parser_path_ephemeral_state_seeded(
self, parsed_derenderer, monkeypatch, leading_space_ids
):
"""The parser path's per-call detok window is seeded too, so
`parse_delta` sees the same text as coupled serving."""
class _EchoParser(Parser):
def parse_delta(
self,
delta_text,
delta_token_ids,
request,
prompt_token_ids=None,
*,
finished,
):
return DeltaMessage(content=delta_text) if delta_text else None
monkeypatch.setattr(parsed_derenderer, "parser", _EchoParser)
prompt_ids, output_ids, expected = leading_space_ids
chunk, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(output_ids, finish_reason="stop"),
chat_request=_chat_request(),
prompt_token_ids=prompt_ids,
)
assert chunk.choices[0].delta.content == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"stream_method",
["derender_chat_stream", "derender_completion_stream"],
)
async def test_streaming_detokenization_runs_off_event_loop(
derenderer, monkeypatch, stream_method
):
"""Streaming detokenization runs on the renderer executor."""
event_loop_thread_id = threading.get_ident()
executor_thread_id = derenderer.renderer._executor.submit(
threading.get_ident
).result()
detokenize_thread_id = None
original_detokenize = online_derenderer_module.detokenize_incrementally
def record_detokenize_thread(*args, **kwargs):
nonlocal detokenize_thread_id
detokenize_thread_id = threading.get_ident()
return original_detokenize(*args, **kwargs)
monkeypatch.setattr(
online_derenderer_module,
"detokenize_incrementally",
record_detokenize_thread,
)
await getattr(derenderer, stream_method)(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([10]),
)
assert detokenize_thread_id is not None
assert detokenize_thread_id == executor_thread_id
assert detokenize_thread_id != event_loop_thread_id
class TestDerenderCompletionStream:
"""derender_completion_stream: streaming output parity with one shot."""
@pytest.mark.asyncio
async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
"""Sum of streaming text chunks == one shot tokenizer.decode."""
token_ids = tokenizer.encode("streaming completion test")[:10]
mid = len(token_ids) // 2
state = DerenderStreamState()
chunk1, state = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids[:mid]),
state=state,
)
chunk2, _ = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids[mid:], finish_reason="stop"),
state=state,
)
streamed_text = chunk1.choices[0].text + chunk2.choices[0].text
one_shot = tokenizer.decode(token_ids, skip_special_tokens=True)
assert streamed_text == one_shot
@pytest.mark.asyncio
async def test_usage_chunk_passthrough(self, derenderer, tokenizer):
"""Usage only final chunk (empty choices) is passed through correctly."""
usage_chunk = _make_usage_chunk(completion_tokens=10, prompt_tokens=5)
chunk, state = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=usage_chunk,
)
assert chunk.choices == []
assert chunk.usage is not None
assert chunk.usage.completion_tokens == 10
assert chunk.usage.prompt_tokens == 5
@pytest.mark.asyncio
async def test_prompt_tokens_in_usage(self, derenderer, tokenizer):
"""prompt_tokens is correctly forwarded into usage on a usage chunk."""
token_ids = tokenizer.encode("hello")[:3]
usage_chunk = _make_usage_chunk(
completion_tokens=len(token_ids), prompt_tokens=7
)
chunk, _ = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=usage_chunk,
prompt_tokens=7,
)
assert chunk.usage is not None
assert chunk.usage.prompt_tokens == 7
@pytest.mark.asyncio
async def test_none_state_initialises_correctly(self, derenderer, tokenizer):
"""Passing state=None (first call) initialises an empty DerenderStreamState."""
token_ids = tokenizer.encode("hello")[:4]
chunk, state = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids),
state=None,
)
assert isinstance(state, DerenderStreamState)
assert chunk.choices[0].text == tokenizer.decode(
token_ids, skip_special_tokens=True
)
@pytest.mark.asyncio
async def test_skip_special_tokens_threaded(self, derenderer, tokenizer):
"""completion_request.skip_special_tokens is honored (not hardcoded True)."""
eos = tokenizer.eos_token_id
if eos is None:
pytest.skip("tokenizer has no eos token to exercise special stripping")
token_ids = tokenizer.encode("hi")[:2] + [eos]
async def _text(skip: bool) -> str:
req = CompletionRequest(
model=MODEL_NAME, prompt="x", skip_special_tokens=skip
)
chunk, _ = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids),
completion_request=req,
)
return chunk.choices[0].text
# skip=False must retain the special token; skip=True must strip it.
assert await _text(False) != await _text(True)
@pytest.mark.asyncio
async def test_finish_reason_forwarded(self, derenderer, tokenizer):
"""finish_reason from the generate chunk reaches the derendered choice."""
token_ids = tokenizer.encode("done")[:2]
chunk, _ = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids, finish_reason="length"),
)
assert chunk.choices[0].finish_reason == "length"
class TestStreamLogprobs:
"""Streaming derender must carry per-chunk logprobs with placeholders
resolved, matching what the generate streaming path emits."""
@pytest.mark.asyncio
async def test_chat_stream_logprobs_resolved_per_chunk(self, derenderer, tokenizer):
"""Each streamed chunk carries logprobs with token_id:N resolved."""
token_ids = tokenizer.encode("hello world")[:6]
mid = len(token_ids) // 2
state = None
for part in (token_ids[:mid], token_ids[mid:]):
chunk, state = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
part, logprobs=_placeholder_logprobs(part)
),
state=state,
)
logprobs = chunk.choices[0].logprobs
assert logprobs is not None and logprobs.content is not None
assert len(logprobs.content) == len(part)
for entry in logprobs.content:
assert not entry.token.startswith("token_id:"), (
f"placeholder not resolved: {entry.token!r}"
)
@pytest.mark.asyncio
async def test_chat_stream_logprobs_multibyte_across_chunks(
self, derenderer, tokenizer
):
"""Byte-fallback correction works when a multi-byte character's
tokens are split across chunks (context carried in stream_state)."""
token_ids = tokenizer.encode("👍", add_special_tokens=False)
if len(token_ids) < 2 or "�" not in tokenizer.decode([token_ids[-1]]):
pytest.skip("Tokenizer does not byte-split this character")
state = None
last_chunk = None
# One token per chunk: every entry after the first needs cross-chunk
# context to resolve without U+FFFD.
for i, tid in enumerate(token_ids):
last_chunk, state = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[tid], logprobs=_placeholder_logprobs([tid])
),
state=state,
)
assert last_chunk is not None
final_entry = last_chunk.choices[0].logprobs.content[0]
assert not final_entry.token.endswith("�"), (
"byte-fallback correction failed across chunk boundary: "
f"{final_entry.token!r}"
)
@pytest.mark.asyncio
async def test_completion_stream_logprobs_text_offset_absolute(
self, derenderer, tokenizer
):
"""text_offset continues across chunks instead of restarting at 0."""
token_ids = tokenizer.encode("hello world")[:6]
mid = len(token_ids) // 2
chunk1, state = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
token_ids[:mid], logprobs=_placeholder_logprobs(token_ids[:mid])
),
)
chunk2, _ = await derenderer.derender_completion_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
token_ids[mid:], logprobs=_placeholder_logprobs(token_ids[mid:])
),
state=state,
)
assert chunk1.choices[0].logprobs.text_offset[0] == 0
assert chunk2.choices[0].logprobs.text_offset[0] == len(chunk1.choices[0].text)
@pytest.mark.asyncio
async def test_stream_logprob_state_stays_bounded(self, derenderer, tokenizer):
"""Carried logprob context never exceeds the 4-token window."""
token_ids = tokenizer.encode("the quick brown fox jumps over the lazy dog")
state = None
for tid in token_ids:
_, state = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[tid], logprobs=_placeholder_logprobs([tid])
),
state=state,
)
assert state is not None
assert len(state.logprob_context_token_ids) <= 4
@pytest.mark.asyncio
async def test_stream_without_logprobs_unchanged(self, derenderer, tokenizer):
"""Chunks without logprobs keep logprobs=None on the output choice."""
token_ids = tokenizer.encode("hello")[:3]
chunk, _ = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids),
)
assert chunk.choices[0].logprobs is None
class TestDerenderChatStream:
"""derender_chat_stream: plain detok branch (no parser)."""
@pytest.mark.asyncio
async def test_role_on_first_chunk_only(self, derenderer, tokenizer):
"""role='assistant' appears in the first chunk, not subsequent ones."""
token_ids = tokenizer.encode("hello world")[:6]
mid = len(token_ids) // 2
state = DerenderStreamState()
chunk1, state = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids[:mid]),
state=state,
)
chunk2, _ = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids[mid:], finish_reason="stop"),
state=state,
)
assert chunk1.choices[0].delta.role == "assistant"
assert chunk2.choices[0].delta.role is None
@pytest.mark.asyncio
async def test_metrics_passthrough(self, derenderer):
metrics = PerRequestMetrics(
speculative_decoding=SpeculativeDecodingMetrics(
mean_acceptance_length=2.0,
draft_acceptance_rate=0.5,
acceptance_histogram=[0, 1],
num_spec_steps=1,
num_accepted_draft_tokens=1,
num_draft_tokens=2,
num_spec_tokens=1,
)
)
chunk, _ = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([], metrics=metrics),
state=DerenderStreamState(),
)
assert chunk.metrics == metrics
@pytest.mark.asyncio
async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
"""Sum of streaming content deltas == one shot decode."""
token_ids = tokenizer.encode("streaming chat derender text")[:10]
mid = len(token_ids) // 2
state = DerenderStreamState()
chunk1, state = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids[:mid]),
state=state,
)
chunk2, _ = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(token_ids[mid:]),
state=state,
)
streamed = (chunk1.choices[0].delta.content or "") + (
chunk2.choices[0].delta.content or ""
)
one_shot = tokenizer.decode(token_ids, skip_special_tokens=True)
assert streamed == one_shot
@pytest.mark.asyncio
async def test_parser_configured_missing_chat_request_raises(
self, parsed_derenderer
):
"""A parser configured model must never fall through to plain detok
even when `chat_request` is omitted. If allowed this would leak raw
reasoning/tool markup into `delta.content`. `ServingDerender`
pre-checks this too (400 before touching the tokenizer). This pins
the `OnlineDerenderer` level backstop."""
with pytest.raises(ValueError, match="chat_request"):
await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([_FakeParser.CONTENT]),
state=None,
chat_request=None,
)
def _chat_request(**kwargs):
kwargs.setdefault("messages", [{"role": "user", "content": "hi"}])
kwargs.setdefault("model", MODEL_NAME)
return ChatCompletionRequest(**kwargs)
class TestDerenderChatStreamParsed:
"""derender_chat_stream: parser branch (replay + parse_delta) exercised
against the deterministic `_FakeParser` so these pin OnlineDerenderer's
own replay/merge/pin/finish_reason logic independent of any real
parser's markup grammar (covered separately by the parser_server backed
integration tests below)."""
@pytest.mark.asyncio
async def test_dispatches_and_emits_content(self, parsed_derenderer):
chunk, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.CONTENT], finish_reason="stop"
),
chat_request=_chat_request(),
)
assert chunk.choices[0].delta.content == "c"
assert chunk.choices[0].delta.role == "assistant"
assert chunk.choices[0].finish_reason == "stop"
assert state.output_token_ids == [_FakeParser.CONTENT]
@pytest.mark.asyncio
async def test_role_sent_once(self, parsed_derenderer):
chat_request = _chat_request()
chunk1, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([_FakeParser.CONTENT]),
chat_request=chat_request,
)
chunk2, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.CONTENT], finish_reason="stop"
),
state=state,
chat_request=chat_request,
)
assert chunk1.choices[0].delta.role == "assistant"
assert chunk2.choices[0].delta.role is None
@pytest.mark.asyncio
async def test_finish_only_chunk_flushes(self, parsed_derenderer):
"""A finish only chunk (no new tokens) still calls `parse_delta`
once with `finished=True` to flush buffered state."""
chat_request = _chat_request()
_, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([_FakeParser.CONTENT]),
chat_request=chat_request,
)
chunk2, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([], finish_reason="stop"),
state=state,
chat_request=chat_request,
)
assert chunk2.choices[0].delta.content == "FLUSH"
assert chunk2.choices[0].finish_reason == "stop"
@pytest.mark.asyncio
async def test_chunking_invariance(self, parsed_derenderer):
"""1 token per chunk vs a single whole chunk assemble identically.
Every chunk (live or replayed) is fed to `parse_delta` at its own
producer granularity (see `_derender_chat_stream_parsed`'s
docstring). This proves that choice doesn't change the assembled
output for a well behaved incremental parser. Each call's effects
fold together the same way regardless of how many tokens land in
one call vs. many."""
token_ids = [
_FakeParser.REASON,
_FakeParser.REASON,
_FakeParser.TOOL_START,
_FakeParser.TOOL_ARG,
_FakeParser.TOOL_ARG,
_FakeParser.CONTENT,
]
chat_request = _chat_request(
tools=[{"type": "function", "function": {"name": "get_weather"}}],
tool_choice="auto",
include_reasoning=True,
)
async def _assemble(chunks: list[list[int]]) -> dict:
state = None
content = ""
reasoning = ""
tool_args = ""
for i, tids in enumerate(chunks):
finish = "stop" if i == len(chunks) - 1 else None
chunk, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(tids, finish_reason=finish),
state=state,
chat_request=chat_request,
)
delta = chunk.choices[0].delta
content += delta.content or ""
reasoning += delta.reasoning or ""
for tc in delta.tool_calls:
if tc.function and tc.function.arguments:
tool_args += tc.function.arguments
return {"content": content, "reasoning": reasoning, "tool_args": tool_args}
whole = await _assemble([token_ids])
one_at_a_time = await _assemble([[t] for t in token_ids])
assert whole == one_at_a_time
assert whole == {"content": "c", "reasoning": "rr", "tool_args": "aa"}
@pytest.mark.asyncio
async def test_replay_preserves_chunk_boundaries(
self, parsed_derenderer, monkeypatch
):
"""A multi-token chunk (e.g. a speculative decoding step) must be
replayed as one `parse_delta` call, not one per token. Parsers
with per-call state such as `history_tool_call_cnt` would
otherwise diverge from standard serving."""
class _CallCountingParser(_FakeParser):
"""Emits how many `parse_delta` calls it has seen."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.num_calls = 0
def parse_delta(
self,
delta_text,
delta_token_ids,
request,
prompt_token_ids=None,
*,
finished,
):
self.num_calls += 1
return DeltaMessage(content=str(self.num_calls))
monkeypatch.setattr(parsed_derenderer, "parser", _CallCountingParser)
chat_request = _chat_request()
_, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([_FakeParser.CONTENT] * 3),
chat_request=chat_request,
)
assert state.output_chunk_lens == [3]
chunk, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.CONTENT], finish_reason="stop"
),
state=state,
chat_request=chat_request,
)
assert chunk.choices[0].delta.content == "2"
@pytest.mark.asyncio
async def test_tool_call_id_pinned_across_chunks(self, parsed_derenderer):
"""Once an index's ID is recorded in `last_tool_call_ids`, a later
id bearing delta for that same index is pinned to the recorded
value rather than a freshly (re-)generated one."""
chat_request = _chat_request(
tools=[{"type": "function", "function": {"name": "get_weather"}}],
tool_choice="auto",
)
chunk1, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([_FakeParser.TOOL_START]),
chat_request=chat_request,
)
first_id = chunk1.choices[0].delta.tool_calls[0].id
assert first_id is not None
assert state.last_tool_call_ids == [first_id]
chunk2, state = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.TOOL_START], finish_reason="stop"
),
state=state,
chat_request=chat_request,
)
assert chunk2.choices[0].delta.tool_calls[0].id == first_id
assert state.last_tool_call_ids == [first_id]
@pytest.mark.asyncio
async def test_finish_reason_rewritten_to_tool_calls(self, parsed_derenderer):
chat_request = _chat_request(
tools=[{"type": "function", "function": {"name": "get_weather"}}],
tool_choice="auto",
)
chunk, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.TOOL_START, _FakeParser.TOOL_ARG], finish_reason="stop"
),
chat_request=chat_request,
)
assert chunk.choices[0].finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_finish_reason_stop_for_named_tool_choice(self, parsed_derenderer):
# ChatCompletionRequest's `check_tool_usage` is a mode="before"
# validator, so it sees the raw value and only accepts "auto",
# "required" or a dict, never an already built
# ChatCompletionNamedToolChoiceParam.
chat_request = _chat_request(
tools=[{"type": "function", "function": {"name": "get_weather"}}],
tool_choice={"type": "function", "function": {"name": "get_weather"}},
)
chunk, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.TOOL_START, _FakeParser.TOOL_ARG], finish_reason="stop"
),
chat_request=chat_request,
)
assert chunk.choices[0].finish_reason == "stop"
@pytest.mark.asyncio
async def test_include_reasoning_false_suppresses_reasoning(
self, parsed_derenderer
):
chat_request = _chat_request(include_reasoning=False)
chunk, _ = await parsed_derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk(
[_FakeParser.REASON, _FakeParser.CONTENT], finish_reason="stop"
),
chat_request=chat_request,
)
delta = chunk.choices[0].delta
assert delta.reasoning is None
assert delta.content == "c"
# ---------------------------------------------------------------------------
# Harmony / GPT-OSS replay — unit, no server
# ---------------------------------------------------------------------------
HARMONY_MODEL = "openai/gpt-oss-20b"
HARMONY_REASONING = "The user wants 2 plus 3."
HARMONY_ANSWER = "The answer is 5."
HARMONY_PROMPT = "<|start|>user<|message|>Add 2 and 3.<|end|><|start|>assistant"
HARMONY_OUTPUT = (
f"<|channel|>analysis<|message|>{HARMONY_REASONING}<|end|>"
f"<|start|>assistant<|channel|>final<|message|>{HARMONY_ANSWER}<|return|>"
)
@pytest.fixture(scope="module")
def harmony_encode():
"""Encoder for canned GPT-OSS harmony token sequences."""
pytest.importorskip("openai_harmony")
# Pre-caches the o200k_base BPE file that openai-harmony's Rust backend
# downloads on first use, same as the sibling suite's E2E harmony tests.
from tests.entrypoints.scale_out.derender.test_derender import (
_ensure_harmony_vocab,
)
from vllm.entrypoints.openai.parser.harmony_utils import get_encoding
_ensure_harmony_vocab()
encoding = get_encoding()
def _encode(harmony_str: str) -> list[int]:
return encoding.encode(harmony_str, allowed_special="all")
return _encode
@pytest.fixture(scope="module")
def harmony_tokenizer():
pytest.importorskip("openai_harmony")
return get_tokenizer(HARMONY_MODEL, trust_remote_code=True)
@pytest.fixture(scope="module")
def harmony_derenderer(harmony_tokenizer):
"""OnlineDerenderer whose parser resolves to the real `HarmonyParser`.
Same shape as `parsed_derenderer` (mocked renderer, real executor) but
with a real tokenizer and parser, so the replay path is exercised
against Harmony's actual channel grammar rather than a stub.
"""
from vllm.parser.harmony import HarmonyParser
renderer = MagicMock()
renderer.get_tokenizer.return_value = harmony_tokenizer
renderer._executor = ThreadPoolExecutor(max_workers=2)
model_config = MagicMock()
model_config.model = HARMONY_MODEL
model_config.hf_config.model_type = "gpt_oss"
model_config.hf_text_config.model_type = "gpt_oss"
model_config.hf_overrides = None
dr = OnlineDerenderer(
model_config=model_config,
renderer=renderer,
request_logger=None,
chat_template=None,
chat_template_content_format="string",
enable_auto_tools=True,
tool_parser="openai",
reasoning_parser="openai_gptoss",
)
assert dr.use_harmony
assert dr.parser is HarmonyParser
return dr
async def _stream_harmony_deltas(
derenderer,
chat_request,
output_ids: list[int],
prompt_ids: list[int],
chunk_size: int,
) -> list[DeltaMessage]:
"""Drive `output_ids` through the parser path in fixed size chunks.
Returns the per chunk `DeltaMessage`s in order so callers can assert on
intermediate emissions, not just the assembled result.
"""
deltas: list[DeltaMessage] = []
state = None
for start in range(0, len(output_ids), chunk_size):
tids = output_ids[start : start + chunk_size]
is_last = start + chunk_size >= len(output_ids)
chunk, state = await derenderer.derender_chat_stream(
model=HARMONY_MODEL,
generate_chunk=_make_stream_chunk(
tids, finish_reason="stop" if is_last else None
),
state=state,
chat_request=chat_request,
prompt_token_ids=prompt_ids,
)
deltas.append(chunk.choices[0].delta)
return deltas
class TestDerenderChatStreamHarmony:
"""derender_chat_stream: replay + `parse_delta` against real HarmonyParser.
Skipped where `openai_harmony` is not installed. `prompt_token_ids` is
threaded through for call shape fidelity only, since HarmonyParser
derives its state from the output tokens alone.
"""
@pytest.mark.asyncio
async def test_reasoning_never_leaks_as_content_midstream(
self, harmony_derenderer, harmony_encode
):
"""Regression guard for the RFC's original replay + diff design.
`HarmonyParser.parse()` always flushes to EOS. Mid-stream that
raises `HarmonyError` and the recovery branch re-emits the in
flight message on the `final` channel, so partial analysis surfaces
as content. Replay + `parse_delta` must never do that. Driven one
token per chunk, the finest granularity a client can produce.
"""
output_ids = harmony_encode(HARMONY_OUTPUT)
prompt_ids = harmony_encode(HARMONY_PROMPT)
chat_request = _chat_request(model=HARMONY_MODEL, include_reasoning=True)
deltas = await _stream_harmony_deltas(
harmony_derenderer, chat_request, output_ids, prompt_ids, chunk_size=1
)
reasoning = ""
content = ""
for i, delta in enumerate(deltas):
reasoning += delta.reasoning or ""
content += delta.content or ""
# Any analysis text emitted as content breaks the prefix
# property, since the two channels share no prefix here.
assert HARMONY_ANSWER.startswith(content), (
f"content after chunk {i} is not a prefix of the final "
f"channel text: {content!r}"
)
assert HARMONY_REASONING.startswith(reasoning)
assert not delta.tool_calls
assert reasoning == HARMONY_REASONING
assert content == HARMONY_ANSWER
@pytest.mark.asyncio
async def test_stream_matches_batch(self, harmony_derenderer, harmony_encode):
"""Streamed assembly equals one shot `/derender` over the same IDs."""
output_ids = harmony_encode(HARMONY_OUTPUT)
prompt_ids = harmony_encode(HARMONY_PROMPT)
chat_request = _chat_request(model=HARMONY_MODEL, include_reasoning=True)
deltas = await _stream_harmony_deltas(
harmony_derenderer, chat_request, output_ids, prompt_ids, chunk_size=3
)
reasoning = "".join(d.reasoning or "" for d in deltas)
content = "".join(d.content or "" for d in deltas)
batch_choices = await harmony_derenderer.derender_chat(
GenerateTokensResponse(
request_id="test-harmony-batch",
choices=[
GenerateTokensChoice(
index=0, token_ids=output_ids, finish_reason="stop"
)
],
),
chat_request,
)
message = batch_choices[0].message
assert reasoning == message.reasoning
assert content == message.content
class TestDerenderStreamStateValidation:
"""DerenderStreamState rejects malformed caller supplied offsets/lengths."""
def test_negative_prefix_offset_rejected(self):
with pytest.raises(ValidationError):
DerenderStreamState(prefix_offset=-1)
def test_negative_read_offset_rejected(self):
with pytest.raises(ValidationError):
DerenderStreamState(read_offset=-1)
def test_prev_tokens_over_cap_rejected(self):
with pytest.raises(ValidationError):
DerenderStreamState(prev_tokens=["a"] * 1025)
def test_prev_tokens_at_cap_accepted(self):
state = DerenderStreamState(prev_tokens=["a"] * 1024)
assert len(state.prev_tokens) == 1024
def test_output_chunk_lens_mismatch_rejected(self):
with pytest.raises(ValidationError):
DerenderStreamState(output_token_ids=[1, 2, 3], output_chunk_lens=[1, 1])
def test_output_chunk_lens_zero_entry_rejected(self):
with pytest.raises(ValidationError):
DerenderStreamState(output_token_ids=[1, 2], output_chunk_lens=[0, 2])
def test_output_chunk_lens_matching_accepted(self):
state = DerenderStreamState(
output_token_ids=[1, 2, 3], output_chunk_lens=[1, 2]
)
assert state.output_chunk_lens == [1, 2]
_GEN = {"request_id": "t", "choices": []}
_GEN_BAD = {**_GEN, "prompt_token_ids": [-1]}
@pytest.mark.parametrize(
"model_cls,payload",
[
(DerenderChatRequest, {"generate_response": _GEN, "prompt_token_ids": [-1]}),
(DerenderChatRequest, {"generate_response": _GEN_BAD}),
(
DerenderCompletionRequest,
{"generate_responses": [_GEN], "prompt_token_ids": [[-1]]},
),
(
DerenderChatStreamRequest,
{"stream": True, "generate_chunk": _GEN, "prompt_token_ids": [-1]},
),
(
DerenderCompletionStreamRequest,
{"stream": True, "generate_chunk": _GEN_BAD},
),
],
)
def test_negative_prompt_token_ids_rejected(model_cls, payload):
"""Negative prompt ids would reach convert_ids_to_tokens, so they get a
400 at parse time like `GenerateRequest.token_ids` does."""
with pytest.raises(ValidationError, match="prompt_token_ids"):
model_cls.model_validate(payload)
class TestServingDerenderStreamErrorHandling:
"""Malformed stream_state must surface as 400 and not an unhandled 500."""
def _make_serving(self, side_effect: Exception):
models = MagicMock()
models.is_base_model.return_value = True
models.model_config = MagicMock()
models.model_config.max_model_len = 100_000
online_derenderer = MagicMock()
online_derenderer.parser = None
online_derenderer.derender_completion_stream = AsyncMock(
side_effect=side_effect
)
online_derenderer.derender_chat_stream = AsyncMock(side_effect=side_effect)
return ServingDerender(models=models, online_derenderer=online_derenderer)
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", [KeyError("bad byte"), IndexError("oob")])
async def test_completion_stream_bad_state_returns_400(self, exc):
serving = self._make_serving(exc)
request = DerenderCompletionStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2]),
stream_state=DerenderStreamState(),
)
result = await serving.derender_completion_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", [KeyError("bad byte"), IndexError("oob")])
async def test_chat_stream_bad_state_returns_400(self, exc):
serving = self._make_serving(exc)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2]),
stream_state=DerenderStreamState(),
)
result = await serving.derender_chat_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
class TestServingDerenderStreamValidation:
"""If checks cannot run in `derender_chat_stream_response`: all must
reject with 400 before ever calling into `online_derenderer` (i.e.
before touching the tokenizer)."""
def _make_serving(self, *, parser_configured: bool, max_model_len: int = 100_000):
models = MagicMock()
models.is_base_model.return_value = True
models.model_config = MagicMock()
models.model_config.max_model_len = max_model_len
online_derenderer = MagicMock()
online_derenderer.parser = MagicMock() if parser_configured else None
online_derenderer.derender_chat_stream = AsyncMock(
return_value=(
ChatCompletionStreamResponse(id="t", model=MODEL_NAME, choices=[]),
DerenderStreamState(),
)
)
return ServingDerender(models=models, online_derenderer=online_derenderer)
@pytest.mark.asyncio
async def test_missing_chat_request_with_parser_rejected(self):
serving = self._make_serving(parser_configured=True)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2]),
)
result = await serving.derender_chat_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
assert "chat_request" in result.error.message
serving.online_derenderer.derender_chat_stream.assert_not_called()
@pytest.mark.asyncio
async def test_missing_chat_request_without_parser_ok(self):
serving = self._make_serving(parser_configured=False)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2]),
)
result = await serving.derender_chat_stream_response(request)
assert not isinstance(result, ErrorResponse)
@pytest.mark.asyncio
async def test_missing_prompt_token_ids_with_parser_rejected(self):
"""A parser configured model must reject a missing prompt_token_ids
the same way it rejects a missing chat_request. Without it,
parse_delta cannot tell whether the prompt left reasoning open and
would misclassify reasoning content as plain content."""
serving = self._make_serving(parser_configured=True)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2]),
chat_request=_chat_request(),
)
result = await serving.derender_chat_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
assert "prompt_token_ids" in result.error.message
serving.online_derenderer.derender_chat_stream.assert_not_called()
@pytest.mark.asyncio
async def test_prompt_token_ids_present_with_parser_ok(self):
serving = self._make_serving(parser_configured=True)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2]),
chat_request=_chat_request(),
prompt_token_ids=[1, 2, 3],
)
result = await serving.derender_chat_stream_response(request)
assert not isinstance(result, ErrorResponse)
@pytest.mark.asyncio
async def test_oversized_output_token_ids_rejected(self):
serving = self._make_serving(parser_configured=False, max_model_len=4)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1, 2, 3]),
stream_state=DerenderStreamState(
output_token_ids=[1, 2], output_chunk_lens=[2]
),
)
result = await serving.derender_chat_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
assert "max_model_len" in result.error.message
serving.online_derenderer.derender_chat_stream.assert_not_called()
@pytest.mark.asyncio
async def test_oversized_prompt_token_ids_rejected(self):
"""prompt_token_ids is caller supplied and otherwise unbounded. A
parser configured deployment rescans it in full on every chunk
(is_reasoning_end / adjust_initial_state_from_prompt), so it must be
bounded the same way output_token_ids is."""
serving = self._make_serving(parser_configured=True, max_model_len=4)
request = DerenderChatStreamRequest(
stream=True,
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([1]),
chat_request=_chat_request(),
prompt_token_ids=[1, 2, 3, 4, 5],
)
result = await serving.derender_chat_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
assert "max_model_len" in result.error.message
serving.online_derenderer.derender_chat_stream.assert_not_called()
@pytest.mark.asyncio
async def test_too_many_choices_rejected(self):
"""Each streamed chunk contains at most one choice (a single
DerenderStreamState is threaded through every choice). The check
could never fire since derender_chat_stream itself rejects anything
above 1 first."""
serving = self._make_serving(parser_configured=False)
two_choices = GenerateTokensStreamResponse(
request_id="t",
choices=[
GenerateTokensStreamChoice(index=i, token_ids=[1]) for i in range(2)
],
)
request = DerenderChatStreamRequest(
stream=True, model=MODEL_NAME, generate_chunk=two_choices
)
result = await serving.derender_chat_stream_response(request)
assert isinstance(result, ErrorResponse)
assert result.error.code == 400
assert "at most one choice" in result.error.message
serving.online_derenderer.derender_chat_stream.assert_not_called()
# ---------------------------------------------------------------------------
# Integration tests — require a live render server
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def server():
with RemoteLaunchRenderServer(MODEL_NAME, []) as remote_server:
yield remote_server
@pytest_asyncio.fixture
async def client(server):
async with httpx.AsyncClient(
base_url=server.url_for(""), timeout=30.0
) as http_client:
yield http_client
async def _render_chat(client) -> dict:
"""Render a minimal chat request and return the GenerateRequest dict."""
resp = await client.post(
"/v1/chat/completions/render",
json={
"model": MODEL_NAME,
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert resp.status_code == 200
return resp.json()
@pytest.mark.asyncio
async def test_streaming_completion_derender_roundtrip(client):
"""Streaming completions derender: chunked text == non streaming text."""
gen_req = await _render_chat(client)
token_ids: list[int] = gen_req["token_ids"][:8]
mid = len(token_ids) // 2
chunk1_ids, chunk2_ids = token_ids[:mid], token_ids[mid:]
# Non streaming baseline.
non_stream_resp = await client.post(
"/v1/completions/derender",
json={
"model": MODEL_NAME,
"generate_responses": [
{
"request_id": "test-ns",
"choices": [
{
"index": 0,
"token_ids": token_ids,
"finish_reason": "stop",
}
],
}
],
},
)
assert non_stream_resp.status_code == 200
expected_text = non_stream_resp.json()["choices"][0]["text"]
# Streaming call 1.
r1 = await client.post(
"/v1/completions/derender",
json={
"stream": True,
"model": MODEL_NAME,
"generate_chunk": {
"request_id": "test-s",
"choices": [
{"index": 0, "token_ids": chunk1_ids, "finish_reason": None}
],
},
"stream_state": None,
},
)
assert r1.status_code == 200
d1 = r1.json()
text1 = d1["chunk"]["choices"][0]["text"]
state1 = d1["stream_state"]
# Streaming call 2 (final chunk).
r2 = await client.post(
"/v1/completions/derender",
json={
"stream": True,
"model": MODEL_NAME,
"generate_chunk": {
"request_id": "test-s",
"choices": [
{"index": 0, "token_ids": chunk2_ids, "finish_reason": "stop"}
],
},
"stream_state": state1,
},
)
assert r2.status_code == 200
text2 = r2.json()["chunk"]["choices"][0]["text"]
assert text1 + text2 == expected_text
@pytest.mark.asyncio
async def test_streaming_chat_derender_roundtrip(client):
"""Streaming chat derender (plain detok): chunked text == non streaming text."""
gen_req = await _render_chat(client)
token_ids: list[int] = gen_req["token_ids"][:8]
mid = len(token_ids) // 2
chunk1_ids, chunk2_ids = token_ids[:mid], token_ids[mid:]
# Non streaming baseline.
ns = await client.post(
"/v1/chat/completions/derender",
json={
"model": MODEL_NAME,
"generate_response": {
"request_id": "test-ns",
"choices": [
{
"index": 0,
"token_ids": token_ids,
"finish_reason": "stop",
}
],
},
},
)
assert ns.status_code == 200
expected_content = ns.json()["choices"][0]["message"]["content"]
# Streaming call 1.
r1 = await client.post(
"/v1/chat/completions/derender",
json={
"stream": True,
"model": MODEL_NAME,
"generate_chunk": {
"request_id": "test-s",
"choices": [
{"index": 0, "token_ids": chunk1_ids, "finish_reason": None}
],
},
"stream_state": None,
},
)
assert r1.status_code == 200
d1 = r1.json()
text1 = d1["chunk"]["choices"][0]["delta"].get("content") or ""
state1 = d1["stream_state"]
# role=assistant on the first chunk
assert d1["chunk"]["choices"][0]["delta"].get("role") == "assistant"
# Streaming call 2.
r2 = await client.post(
"/v1/chat/completions/derender",
json={
"stream": True,
"model": MODEL_NAME,
"generate_chunk": {
"request_id": "test-s",
"choices": [
{"index": 0, "token_ids": chunk2_ids, "finish_reason": "stop"}
],
},
"stream_state": state1,
},
)
assert r2.status_code == 200
d2 = r2.json()
text2 = d2["chunk"]["choices"][0]["delta"].get("content") or ""
# role must NOT be repeated on subsequent chunks
assert d2["chunk"]["choices"][0]["delta"].get("role") is None
assert text1 + text2 == expected_content
@pytest.mark.asyncio
async def test_streaming_derender_invalid_body_returns_400(client):
"""Missing required field in streaming request returns 400."""
r = await client.post(
"/v1/completions/derender",
json={
"stream": True,
# missing required 'model' and 'generate_chunk'
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_streaming_derender_non_object_body_returns_400(client):
"""A non object JSON body (e.g. a list) returns 400, not a 500."""
r = await client.post(
"/v1/completions/derender",
json=[1, 2, 3],
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_streaming_usage_chunk(client):
"""Usage only final chunk is forwarded with correct token counts."""
gen_req = await _render_chat(client)
token_ids: list[int] = gen_req["token_ids"][:6]
state: dict = {}
# Send content chunk first.
r1 = await client.post(
"/v1/completions/derender",
json={
"stream": True,
"model": MODEL_NAME,
"generate_chunk": {
"request_id": "usage-test",
"choices": [
{"index": 0, "token_ids": token_ids, "finish_reason": "stop"}
],
},
"stream_state": None,
},
)
assert r1.status_code == 200
state = r1.json()["stream_state"]
# Send usage only final chunk.
r2 = await client.post(
"/v1/completions/derender",
json={
"stream": True,
"model": MODEL_NAME,
"generate_chunk": {
"request_id": "usage-test",
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": len(token_ids),
"total_tokens": 10 + len(token_ids),
},
},
"stream_state": state,
"prompt_tokens": 10,
},
)
assert r2.status_code == 200
d2 = r2.json()
assert d2["chunk"]["choices"] == []
assert d2["chunk"]["usage"]["prompt_tokens"] == 10
assert d2["chunk"]["usage"]["completion_tokens"] == len(token_ids)
# ---------------------------------------------------------------------------
# Integration tests for parser configured (reasoning + tool calls) require a
# live render server. Mirrors the parser_server / parser_tokenizer pattern
# from test_derender.py.
# ---------------------------------------------------------------------------
PARSER_MODEL = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
_PARSER_TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
@pytest.fixture(scope="module")
def parser_server():
args = [
"--enable-auto-tool-choice",
"--tool-call-parser",
"hermes",
"--reasoning-parser",
"deepseek_r1",
]
with RemoteLaunchRenderServer(PARSER_MODEL, args) as remote_server:
yield remote_server
@pytest_asyncio.fixture
async def parser_client(parser_server):
async with httpx.AsyncClient(
base_url=parser_server.url_for(""), timeout=60.0
) as http_client:
yield http_client
@pytest.fixture(scope="module")
def parser_tokenizer():
return get_tokenizer(PARSER_MODEL)
def _require_parser_markers(tokenizer, text: str, *markers: str) -> list[int]:
"""Encode text and skip the test if any marker is lost in roundtrip."""
ids = tokenizer.encode(text, add_special_tokens=False)
decoded = tokenizer.decode(ids, skip_special_tokens=False)
for m in markers:
if m not in decoded:
pytest.skip(f"Marker {m!r} lost in encode->decode roundtrip")
return ids
async def _render_parser_chat(client, messages: list[dict]) -> dict:
resp = await client.post(
"/v1/chat/completions/render",
json={"model": PARSER_MODEL, "messages": messages},
)
assert resp.status_code == 200, resp.text
return resp.json()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"chunking",
[
"whole",
"per_token",
pytest.param(
"triples",
marks=pytest.mark.xfail(
reason="<think> leaks into reasoning when it shares a "
"multi-token delta with reasoning text (vllm#55195)"
),
),
],
)
async def test_stream_parsed_matches_batch_reasoning(
parser_client, parser_tokenizer, chunking
):
"""Streamed == batch: assembled reasoning/content equal the non
streaming `/derender` result over the same token IDs, for every
chunking of the same output (chunking invariance)."""
messages = [{"role": "user", "content": "What is 2+3?"}]
gen_req = await _render_parser_chat(parser_client, messages)
reasoning_text = "The user wants 2 plus 3. That is 5."
answer_text = "The answer is 5."
output_text = f"<think>{reasoning_text}</think>{answer_text}"
output_ids = _require_parser_markers(parser_tokenizer, output_text, "</think>")
chat_request = {
"model": PARSER_MODEL,
"messages": messages,
"include_reasoning": True,
}
batch_resp = await parser_client.post(
"/v1/chat/completions/derender",
json={
"model": PARSER_MODEL,
"generate_response": {
"request_id": "batch",
"choices": [
{"index": 0, "token_ids": output_ids, "finish_reason": "stop"}
],
},
"prompt_tokens": len(gen_req["token_ids"]),
"chat_request": chat_request,
},
)
assert batch_resp.status_code == 200, batch_resp.text
batch_msg = batch_resp.json()["choices"][0]["message"]
n = len(output_ids)
chunk_sizes = {
"whole": [n],
"per_token": [1] * n,
"triples": [3] * (n // 3) + [n % 3],
}[chunking]
chunk_sizes = [c for c in chunk_sizes if c > 0]
streamed = await stream_chat_derender(
parser_client,
output_ids,
chunk_sizes,
chat_request,
len(gen_req["token_ids"]),
gen_req["token_ids"],
)
assert streamed["content"] == batch_msg["content"]
assert streamed["reasoning"] == batch_msg.get("reasoning")
@pytest.mark.asyncio
async def test_stream_parsed_matches_batch_tool_call(parser_client, parser_tokenizer):
"""Tool call name+id+arguments parity between streamed and batch, plus
the finish_reason -> "tool_calls" rewrite for auto tool choice."""
messages = [{"role": "user", "content": "Weather in Paris?"}]
gen_req = await _render_parser_chat(parser_client, messages)
output_text = (
"<think>Let me check.</think>"
'<tool_call>\n{"name": "get_weather", '
'"arguments": {"city": "Paris"}}\n</tool_call>'
)
output_ids = _require_parser_markers(
parser_tokenizer, output_text, "</think>", "<tool_call>", "</tool_call>"
)
chat_request = {
"model": PARSER_MODEL,
"messages": messages,
"tools": _PARSER_TOOLS,
"tool_choice": "auto",
}
batch_resp = await parser_client.post(
"/v1/chat/completions/derender",
json={
"model": PARSER_MODEL,
"generate_response": {
"request_id": "batch",
"choices": [
{"index": 0, "token_ids": output_ids, "finish_reason": "stop"}
],
},
"prompt_tokens": len(gen_req["token_ids"]),
"chat_request": chat_request,
},
)
assert batch_resp.status_code == 200, batch_resp.text
batch_choice = batch_resp.json()["choices"][0]
if not batch_choice["message"]["tool_calls"]:
pytest.skip("Model did not emit a tool call")
for chunk_sizes in ([len(output_ids)], [1] * len(output_ids)):
# Snapshot of tool_calls[0]["arguments"] after every chunk, to check
# it only ever grows by appending. It never retracts or duplicates
# already streamed text. That's the exact failure mode the
# replay + parse_delta design (over diff-based streaming) exists to
# avoid. Parity with the batch result alone wouldn't catch a
# transient mid-stream regression.
arg_snapshots: list[str] = []
def _record(
tool_calls: list[dict], _snapshots: list[str] = arg_snapshots
) -> None:
if tool_calls and tool_calls[0]["arguments"]:
_snapshots.append(tool_calls[0]["arguments"])
streamed = await stream_chat_derender(
parser_client,
output_ids,
chunk_sizes,
chat_request,
len(gen_req["token_ids"]),
gen_req["token_ids"],
on_chunk=_record,
)
assert streamed["tool_calls"]
assert streamed["tool_calls"][0]["name"] == "get_weather"
assert streamed["tool_calls"][0]["id"] is not None
assert json.loads(streamed["tool_calls"][0]["arguments"]) == json.loads(
batch_choice["message"]["tool_calls"][0]["function"]["arguments"]
)
assert streamed["finish_reason"] == "tool_calls"
assert arg_snapshots, "expected at least one tool-call argument delta"
for prev, curr in zip(arg_snapshots, arg_snapshots[1:]):
assert curr.startswith(prev), (
f"tool-call arguments regressed: {prev!r} -> {curr!r}"
)
assert arg_snapshots[-1] == streamed["tool_calls"][0]["arguments"]
@pytest.mark.asyncio
async def test_stream_parsed_cjk_across_chunk_boundaries(
parser_client, parser_tokenizer
):
"""CJK/emoji tokens split across chunk boundaries with a parser active
across the reasoning -> content transition (parser path regression
guard analogous to #46159 in the plain detok path)."""
messages = [{"role": "user", "content": "Reply in Chinese"}]
gen_req = await _render_parser_chat(parser_client, messages)
reasoning_text = "思考中"
answer_text = "你好世界 😀"
output_text = f"<think>{reasoning_text}</think>{answer_text}"
output_ids = _require_parser_markers(parser_tokenizer, output_text, "</think>")
chat_request = {
"model": PARSER_MODEL,
"messages": messages,
"include_reasoning": True,
}
streamed = await stream_chat_derender(
parser_client,
output_ids,
[1] * len(output_ids),
chat_request,
len(gen_req["token_ids"]),
gen_req["token_ids"],
)
assert "�" not in (streamed["content"] or "")
assert "�" not in (streamed["reasoning"] or "")
assert answer_text in (streamed["content"] or "")
@pytest.mark.asyncio
async def test_stream_parsed_include_reasoning_false(parser_client, parser_tokenizer):
"""include_reasoning=False emits no reasoning deltas on the streaming
parser path."""
messages = [{"role": "user", "content": "What is 2+3?"}]
gen_req = await _render_parser_chat(parser_client, messages)
output_text = "<think>reasoning here</think>The answer is 5."
output_ids = _require_parser_markers(parser_tokenizer, output_text, "</think>")
chat_request = {
"model": PARSER_MODEL,
"messages": messages,
"include_reasoning": False,
}
streamed = await stream_chat_derender(
parser_client,
output_ids,
[1] * len(output_ids),
chat_request,
len(gen_req["token_ids"]),
gen_req["token_ids"],
)
assert streamed["reasoning"] is None
@pytest.mark.asyncio
async def test_stream_parsed_missing_chat_request_rejected(parser_client):
"""Parser configured + no chat_request on the streaming endpoint -> 400
(the live-server counterpart to the mocked
`TestServingDerenderStreamValidation` checks above)."""
resp = await parser_client.post(
"/v1/chat/completions/derender",
json={
"stream": True,
"model": PARSER_MODEL,
"generate_chunk": {
"request_id": "reject-test",
"choices": [{"index": 0, "token_ids": [1, 2], "finish_reason": None}],
},
},
)
assert resp.status_code == 400
assert "chat_request" in resp.json()["error"]["message"]
@pytest.mark.asyncio
async def test_stream_parsed_missing_prompt_token_ids_rejected(parser_client):
"""Parser configured + chat_request given but no prompt_token_ids on the
streaming endpoint -> 400 (the live-server counterpart to the mocked
`test_missing_prompt_token_ids_with_parser_rejected` above). Without
prompt_token_ids, parse_delta cannot tell whether the prompt left
reasoning open and would silently misclassify reasoning as content."""
messages = [{"role": "user", "content": "Hello"}]
resp = await parser_client.post(
"/v1/chat/completions/derender",
json={
"stream": True,
"model": PARSER_MODEL,
"generate_chunk": {
"request_id": "reject-test",
"choices": [{"index": 0, "token_ids": [1, 2], "finish_reason": None}],
},
"chat_request": {
"model": PARSER_MODEL,
"messages": messages,
"include_reasoning": True,
},
},
)
assert resp.status_code == 400
assert "prompt_token_ids" in resp.json()["error"]["message"]