Skip to content

Commit 7157792

Browse files
Bslabe123jjk-g
authored andcommitted
Add regression test for issue kubernetes-sigs#364 (kubernetes-sigs#460)
Addresses: kubernetes-sigs#364 Adds a regression test that exercises the full streaming pipeline (raw SSE bytes → `parse_sse_stream` → `ChatCompletionAPIData.process_response` → `summarize_requests`) with multi-token chunks and asserts `output_tokens` reflects per-chunk tokenizer counts, not chunk counts.
1 parent d75be9b commit 7157792

1 file changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Regression test for issue #364.
15+
16+
Pre-#410, output_tokens was effectively len(response_chunks): each SSE chunk
17+
was charged as one token regardless of how many tokens its delta contained.
18+
For servers that batch multiple tokens per chunk (typical for vLLM under
19+
load), this undercounted output_tokens by the average chunk size, producing
20+
the 2x TPOT / 0.5x OTPS gap reported against `vllm bench`.
21+
22+
This test exercises the full pipeline (raw SSE bytes -> parse_sse_stream ->
23+
ChatCompletionAPIData.process_response -> summarize_requests) with
24+
multi-token chunks and asserts that output_tokens reflects actual token
25+
counts, not chunk counts.
26+
"""
27+
28+
from typing import AsyncGenerator, List, cast
29+
from unittest.mock import MagicMock
30+
31+
import pytest
32+
from aiohttp import ClientResponse
33+
34+
from inference_perf.apis.base import (
35+
RequestLifecycleMetric,
36+
StreamedInferenceResponseInfo,
37+
)
38+
from inference_perf.apis.chat import ChatCompletionAPIData, ChatMessage
39+
from inference_perf.config import APIConfig, APIType
40+
from inference_perf.reportgen.base import summarize_requests
41+
42+
43+
class FakeStreamingResponse:
44+
"""Minimal aiohttp ClientResponse stand-in that yields preset SSE bytes."""
45+
46+
def __init__(self, chunks: List[bytes]) -> None:
47+
self.status = 200
48+
self.headers = {"content-type": "text/event-stream"}
49+
self._chunks = chunks
50+
self.content = self._make_content()
51+
52+
def _make_content(self) -> MagicMock:
53+
content = MagicMock()
54+
55+
async def iter_any() -> AsyncGenerator[bytes, None]:
56+
for chunk in self._chunks:
57+
yield chunk
58+
59+
content.iter_any = iter_any
60+
return content
61+
62+
63+
def _build_sse_stream(chunk_texts: List[str], completion_tokens: int) -> bytes:
64+
"""Build an OpenAI-style SSE byte stream with one delta per chunk and a
65+
trailing usage chunk (as emitted with stream_options.include_usage=true).
66+
"""
67+
parts = [f'data: {{"choices":[{{"delta":{{"content":"{text}"}}}}]}}\n\n'.encode() for text in chunk_texts]
68+
parts.append(f'data: {{"choices":[],"usage":{{"completion_tokens":{completion_tokens}}}}}\n\n'.encode())
69+
parts.append(b"data: [DONE]\n\n")
70+
return b"".join(parts)
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_multi_token_chunks_count_tokens_not_chunks() -> None:
75+
"""Each chunk's delta carries multiple tokens; output_tokens must equal
76+
the per-chunk token sum, not the chunk count.
77+
78+
Pre-#410: output_tokens == len(chunks) == 4. TPOT computed against 4
79+
"tokens" overstates per-token latency by ~2x for 2-tokens-per-chunk
80+
streams. Post-#410 the per-chunk tokenizer count drives output_tokens,
81+
so output_tokens == 8.
82+
"""
83+
chunk_texts = ["aa bb", "cc dd", "ee ff", "gg hh"]
84+
sse = _build_sse_stream(chunk_texts, completion_tokens=8)
85+
response = FakeStreamingResponse([sse])
86+
87+
tokenizer = MagicMock()
88+
tokenizer.count_tokens = MagicMock(side_effect=lambda text: len(text.split()))
89+
90+
config = APIConfig(type=APIType.Chat, streaming=True)
91+
data = ChatCompletionAPIData(messages=[ChatMessage(role="user", content="prompt")], max_tokens=100)
92+
93+
info = await data.process_response(cast(ClientResponse, response), config, tokenizer)
94+
95+
assert isinstance(info.response_info, StreamedInferenceResponseInfo)
96+
assert info.response_info.server_usage == {"completion_tokens": 8}
97+
assert len(info.response_info.response_chunks) == len(chunk_texts)
98+
99+
metric = RequestLifecycleMetric(
100+
scheduled_time=0.0, start_time=0.0, end_time=10.0, request_data="prompt", info=info, error=None
101+
)
102+
summarize_requests([metric], [50], tokenizer=tokenizer)
103+
104+
assert isinstance(metric.info.response_info, StreamedInferenceResponseInfo)
105+
# Sum of per-chunk tokens, NOT chunk count. This is the assertion that
106+
# would fail under the pre-#410 implementation.
107+
assert metric.info.response_info.output_tokens == 8
108+
assert metric.info.response_info.output_tokens != len(chunk_texts)
109+
# Per-token timestamps expanded so TPOT/ITL distributions are accurate.
110+
assert len(metric.info.response_info.output_token_times) == 8
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_multi_token_chunks_match_server_usage() -> None:
115+
"""When server_usage.completion_tokens is present, the client-derived
116+
count and the server-reported count should agree for a stream where the
117+
tokenizer matches the server. This is what eliminates the gap against
118+
`vllm bench`, which reads completion_tokens directly.
119+
"""
120+
# Simulate a long stream of multi-token chunks - the regime where #364
121+
# originally surfaced (1k input, 8k output, multi-token chunks).
122+
tokens_per_chunk = 4
123+
num_chunks = 64
124+
chunk_texts = [" ".join([f"t{i}_{j}" for j in range(tokens_per_chunk)]) for i in range(num_chunks)]
125+
expected_total = tokens_per_chunk * num_chunks
126+
127+
sse = _build_sse_stream(chunk_texts, completion_tokens=expected_total)
128+
response = FakeStreamingResponse([sse])
129+
130+
tokenizer = MagicMock()
131+
tokenizer.count_tokens = MagicMock(side_effect=lambda text: len(text.split()))
132+
133+
config = APIConfig(type=APIType.Chat, streaming=True)
134+
data = ChatCompletionAPIData(messages=[ChatMessage(role="user", content="prompt")], max_tokens=expected_total)
135+
136+
info = await data.process_response(cast(ClientResponse, response), config, tokenizer)
137+
metric = RequestLifecycleMetric(
138+
scheduled_time=0.0, start_time=0.0, end_time=10.0, request_data="prompt", info=info, error=None
139+
)
140+
result = summarize_requests([metric], [50], tokenizer=tokenizer)
141+
142+
assert isinstance(metric.info.response_info, StreamedInferenceResponseInfo)
143+
assert metric.info.response_info.output_tokens == expected_total
144+
assert metric.info.response_info.server_usage == {"completion_tokens": expected_total}
145+
146+
# No mismatches between client tokenization and server usage when the
147+
# tokenizer matches; this is the normal-operation expectation.
148+
assert result is not None
149+
assert result.successes["token_count_mismatches"] == 0

0 commit comments

Comments
 (0)