Files
lightseekorg--tokenspeed/test/runtime/test_inline_detokenizer_receiver.py
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

921 lines
34 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Inline detokenization receiver tests.
Drive the ``AsyncLLM._inline_detokenize_one`` helper and the
``BatchTokenIDOut`` dispatch branch. They verify:
1. Flag-off regression — ``BatchTokenIDOut`` still flows through the
raw-token path and produces an out_dict with an empty ``text``.
2. Flag-on inline emit — out_dict gains a ``text`` key populated by
the per-request ``IncrementalDetokenizer`` and matches the shape
the ``BatchStrOut`` branch produces byte-for-byte.
3. Per-request lifecycle — the inline detokenizer is lazily created
per rid, persists across frames for the same rid, and is
independent between rids.
4. Subprocess-vs-inline text parity — for a given sequence of
frames, the cumulative ``state.text`` accumulated through the
inline path equals what ``incremental_decode_batch`` would emit
character-for-character.
5. Stream vs non-stream ``output_ids`` shape, stop trimming
pass-through, and finish-reason propagation.
A ``_StubTokenizerManager`` bypasses ZMQ / ModelConfig / HF-tokenizer
bring-up so the tests can exercise the exact production code path
without GPU or network.
"""
from __future__ import annotations
import os
import sys
import types
import unittest
from typing import Any, Dict, List, Optional
# CI registration (AST-parsed, runtime no-op).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ci_system.ci_register import register_cuda_ci # noqa: E402
register_cuda_ci(est_time=60, suite="runtime-1gpu")
from transformers import AutoTokenizer # noqa: E402
from tokenspeed.runtime.engine.async_llm import AsyncLLM # noqa: E402
from tokenspeed.runtime.engine.collector import ( # noqa: E402
RequestOutputCollector,
)
from tokenspeed.runtime.engine.detokenizer import ( # noqa: E402
DecodeStatus,
IncrementalDetokenizer,
incremental_decode_batch,
)
from tokenspeed.runtime.engine.io_struct import BatchTokenIDOut # noqa: E402
from tokenspeed.runtime.engine.output_processor import ( # noqa: E402
OutputProcessor,
ReqState,
)
_GPT2_TOKENIZER = "gpt2"
# ---------------------------------------------------------------------------
# Stubs
# ---------------------------------------------------------------------------
class _StubTokenizerManager(AsyncLLM):
"""Bypass ZMQ + ModelConfig + HF bring-up for unit tests.
We only need the pieces touched by
``OutputProcessor.handle_batch_output`` and
``_inline_detokenize_one``: ``server_args``, ``tokenizer``, the
``rid_to_state`` map, and a handful of flags. Everything else
that the real ``__init__`` populates (metrics, sockets, model
config) is untouched because these tests never reach those
paths.
"""
def __init__(
self,
tokenizer: Any,
*,
enable_inline_detokenizer: bool = True,
stream_output: bool = True,
speculative_algorithm: Any = None,
) -> None:
self.tokenizer = tokenizer
self.processor = None
self.rid_to_state: Dict[str, ReqState] = {}
self.enable_metrics = False
self.dump_requests_folder = False
self.log_requests = False
# Build a tiny ServerArgs-shaped object so the branch conditions in
# ``handle_batch_output`` keep working without loading the real
# ServerArgs dataclass (which pulls torch through ModelConfig).
self.server_args = types.SimpleNamespace(
enable_inline_detokenizer=enable_inline_detokenizer,
stream_output=stream_output,
speculative_algorithm=speculative_algorithm,
skip_tokenizer_init=False,
)
# OutputProcessor holds a back-reference to this stub via
# ``engine``.
self.output_processor = OutputProcessor(self)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _gpt2_tokenizer() -> Any:
return AutoTokenizer.from_pretrained(_GPT2_TOKENIZER)
def _batch_token_id_out(
rids: List[str],
*,
decode_ids: List[List[int]],
decoded_texts: Optional[List[str]] = None,
read_offsets: Optional[List[int]] = None,
finished_reasons: Optional[List[Optional[Dict[str, Any]]]] = None,
no_stop_trim: Optional[List[bool]] = None,
skip_special_tokens: Optional[List[bool]] = None,
spaces_between_special_tokens: Optional[List[bool]] = None,
**overrides: Any,
) -> BatchTokenIDOut:
"""Build a ``BatchTokenIDOut`` with safe defaults."""
n = len(rids)
defaults: Dict[str, Any] = {
"output_ids": None,
"output_multi_ids": None,
"prompt_tokens": [0] * n,
"completion_tokens": [0] * n,
"cached_tokens": [0] * n,
"spec_verify_ct": [0] * n,
"input_token_logprobs_val": [],
"input_token_logprobs_idx": [],
"output_token_logprobs_val": [],
"output_token_logprobs_idx": [],
"input_top_logprobs_val": [],
"input_top_logprobs_idx": [],
"output_top_logprobs_val": [],
"output_top_logprobs_idx": [],
"input_token_ids_logprobs_val": [],
"input_token_ids_logprobs_idx": [],
"output_token_ids_logprobs_val": [],
"output_token_ids_logprobs_idx": [],
"output_hidden_states": [[] for _ in range(n)],
"batch_accept_draft_tokens": [],
"output_extra_infos": [],
"generated_time": 0,
}
defaults.update(overrides)
return BatchTokenIDOut(
rids=rids,
finished_reasons=(
finished_reasons if finished_reasons is not None else [None] * n
),
decoded_texts=decoded_texts if decoded_texts is not None else [""] * n,
decode_ids=decode_ids,
read_offsets=read_offsets if read_offsets is not None else [0] * n,
skip_special_tokens=(
skip_special_tokens if skip_special_tokens is not None else [True] * n
),
spaces_between_special_tokens=(
spaces_between_special_tokens
if spaces_between_special_tokens is not None
else [True] * n
),
no_stop_trim=no_stop_trim if no_stop_trim is not None else [False] * n,
**defaults,
)
class _StubReqObj:
"""Minimal stand-in for GenerateReqInput used by ``_handle_batch_output``."""
def __init__(
self,
*,
stream: bool = True,
return_logprob: bool = False,
rid: str = "r1",
log_metrics: bool = False,
) -> None:
self.stream = stream
self.return_logprob = return_logprob
self.rid = rid
self.log_metrics = log_metrics
# Fields normally consumed only when return_logprob=True — provide
# benign defaults so the attribute access never raises.
self.top_logprobs_num = []
self.token_ids_logprob = []
self.return_text_in_logprobs = False
def _mk_state(*, stream: bool = True, rid: str = "r1") -> ReqState:
return ReqState(
RequestOutputCollector(),
False,
__import__("asyncio").Event(),
_StubReqObj(stream=stream, rid=rid),
created_time=0.0,
)
def _register(manager: _StubTokenizerManager, state: ReqState) -> None:
manager.rid_to_state[state.obj.rid] = state
# ---------------------------------------------------------------------------
# Flag-off regression: BatchTokenIDOut still takes the raw-token path.
# ---------------------------------------------------------------------------
class TestFlagOffRegression(unittest.TestCase):
"""Flag off → inline path stays dormant. We verify this at the receiver
level: when a ``BatchTokenIDOut`` reaches ``_handle_batch_output`` with
the flag off, the inline helper is never invoked and no
``inline_detokenizer`` is lazily created on the request state.
(The pre-existing raw-token path for ``--skip-tokenizer-init`` requires
``recv_obj.output_ids`` to be populated by the scheduler; we don't
exercise that path here — it isn't changed by this PR.)
"""
def test_flag_off_receiver_does_not_take_inline_branch(self):
tok = _gpt2_tokenizer()
mgr = _StubTokenizerManager(tok, enable_inline_detokenizer=False)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
# Populate output_ids so the raw-token fallback path doesn't crash;
# we only care that the inline branch is NOT taken.
tokens = tok.encode("hello world")
recv = _batch_token_id_out(
["r1"],
decode_ids=[tokens],
output_ids=[tokens],
batch_accept_draft_tokens=[1.5],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
self.assertIsNotNone(out)
# The inline detokenizer does NOT run on this path (the assertion
# this test exists for). What's emitted is the raw-token out_dict,
# which since the D.1-regression hotfix carries an empty ``text``
# key — matching the pre-D.1 BatchStrOut shape that subprocess
# conversion used to guarantee. The state machine that would have
# populated ``state.text`` never ran, so the value is "".
self.assertEqual(out["text"], "")
self.assertEqual(out["meta_info"]["accept_draft_tokens"], 1.5)
self.assertIsNone(state.inline_detokenizer)
self.assertEqual(state.text, "")
# ---------------------------------------------------------------------------
# Inline path basics.
# ---------------------------------------------------------------------------
class TestInlineBasicEmit(unittest.TestCase):
"""Flag-on path produces a BatchStrOut-shape out_dict with ``text``."""
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def test_single_frame_populates_text_and_output_ids(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
source = "The quick brown fox"
ids = self.tok.encode(source)
# Emit all tokens as one finished frame so we can assert the
# final text without partial-UTF-8 deferral buffering.
recv = _batch_token_id_out(
["r1"],
decode_ids=[ids],
finished_reasons=[{"type": "stop", "matched": None}],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
self.assertIn("text", out)
self.assertEqual(out["text"], source)
self.assertEqual(out["output_ids"], ids)
self.assertIs(out["meta_info"]["id"], "r1")
# Inline detokenizer instantiated and still reachable on state.
self.assertIsInstance(state.inline_detokenizer, IncrementalDetokenizer)
def test_second_frame_reuses_per_request_detokenizer(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
first_ids = self.tok.encode("Hello ")
mgr.output_processor.handle_batch_output(
_batch_token_id_out(["r1"], decode_ids=[first_ids])
)
det_after_first = state.inline_detokenizer
self.assertIsNotNone(det_after_first)
second_ids = self.tok.encode("world")
mgr.output_processor.handle_batch_output(
_batch_token_id_out(
["r1"],
decode_ids=[second_ids],
finished_reasons=[{"type": "stop", "matched": None}],
)
)
self.assertIs(state.inline_detokenizer, det_after_first)
# Final state.text must carry the full cumulative decoded string.
self.assertEqual(state.text, self.tok.decode(first_ids + second_ids))
def test_meta_info_includes_finish_and_prompt_tokens(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
ids = self.tok.encode("end.")
recv = _batch_token_id_out(
["r1"],
decode_ids=[ids],
finished_reasons=[{"type": "stop", "matched": None}],
prompt_tokens=[7],
completion_tokens=[len(ids)],
cached_tokens=[0],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
self.assertEqual(out["meta_info"]["prompt_tokens"], 7)
self.assertEqual(out["meta_info"]["completion_tokens"], len(ids))
self.assertEqual(
out["meta_info"]["finish_reason"], {"type": "stop", "matched": None}
)
self.assertTrue(state.finished)
# ---------------------------------------------------------------------------
# output_ids stream-vs-non-stream shape parity.
# ---------------------------------------------------------------------------
class TestOutputIdsShape(unittest.TestCase):
"""Inline path must match BatchStrOut branch's output_ids contract."""
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def test_stream_mode_emits_delta_output_ids(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
ids_a = self.tok.encode("foo ")
ids_b = self.tok.encode("bar")
mgr.output_processor.handle_batch_output(
_batch_token_id_out(["r1"], decode_ids=[ids_a])
)
out1 = state.collector.take()
mgr.output_processor.handle_batch_output(
_batch_token_id_out(
["r1"],
decode_ids=[ids_b],
finished_reasons=[{"type": "stop", "matched": None}],
)
)
out2 = state.collector.take()
# Deltas: first frame is ids_a, second is ids_b.
self.assertEqual(out1["output_ids"], ids_a)
self.assertEqual(out2["output_ids"], ids_b)
# Cumulative state carries the full list.
self.assertEqual(state.output_ids, ids_a + ids_b)
def test_non_stream_mode_emits_full_cumulative_output_ids(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=False, rid="r1")
_register(mgr, state)
ids_a = self.tok.encode("foo ")
ids_b = self.tok.encode("bar")
mgr.output_processor.handle_batch_output(
_batch_token_id_out(["r1"], decode_ids=[ids_a])
)
out1 = state.collector.take()
mgr.output_processor.handle_batch_output(
_batch_token_id_out(
["r1"],
decode_ids=[ids_b],
finished_reasons=[{"type": "stop", "matched": None}],
)
)
out2 = state.collector.take()
# Full copy every frame in non-stream mode.
self.assertEqual(out1["output_ids"], ids_a)
self.assertEqual(out2["output_ids"], ids_a + ids_b)
# ---------------------------------------------------------------------------
# Stop trimming passes through unchanged.
# ---------------------------------------------------------------------------
class TestStopTrimmingPassThrough(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def test_matched_string_is_trimmed_in_text(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
source = "hello STOP world"
ids = self.tok.encode(source)
recv = _batch_token_id_out(
["r1"],
decode_ids=[ids],
finished_reasons=[{"type": "stop", "matched": "STOP"}],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
# Matched stop string and everything after must be trimmed.
self.assertEqual(out["text"], "hello ")
def test_no_stop_trim_flag_preserves_matched_content(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
source = "hello STOP world"
ids = self.tok.encode(source)
recv = _batch_token_id_out(
["r1"],
decode_ids=[ids],
finished_reasons=[{"type": "stop", "matched": "STOP"}],
no_stop_trim=[True],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
self.assertEqual(out["text"], source)
def test_matched_int_with_no_stop_trim_preserves_last_token(self):
# Gap fill for ``matched=int`` (stop-token) case. The two places
# ``trim_matched_stop`` fires inside the inline branch both take a
# different code path for ``matched=int`` than for ``matched=str``:
# 1. ``read_ids = trim_matched_stop(s.decode_ids[surr:], finish,
# no_stop_trim)`` — on the id-list side, matched=int drops the
# last token from ``read_ids`` before ``batch_decode``, which
# shortens the resulting text by whatever that token contributes.
# 2. ``trim_matched_stop(s.decoded_text + new_text, ...)`` — on
# the string side, matched=int falls through and returns the
# output unchanged.
# When ``no_stop_trim=True``, both short-circuit to "return output
# unchanged", so the decoded text must include every token in
# ``decode_ids``. This test locks that interaction on the inline
# path so a later refactor cannot accidentally collapse the
# matched=int case into the matched=str case.
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
source = "Hello world"
ids = self.tok.encode(source)
self.assertGreaterEqual(len(ids), 2)
recv = _batch_token_id_out(
["r1"],
decode_ids=[ids],
finished_reasons=[{"type": "stop", "matched": ids[-1]}],
no_stop_trim=[True],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
# Full source preserved — matched=int + no_stop_trim means no
# token is dropped from read_ids and no string trimming occurs.
self.assertEqual(out["text"], self.tok.decode(ids))
self.assertEqual(state.text, self.tok.decode(ids))
# ---------------------------------------------------------------------------
# Per-request independence.
# ---------------------------------------------------------------------------
class TestPerRequestIndependence(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def test_two_rids_share_one_manager_without_cross_talk(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
s_a = _mk_state(stream=True, rid="a")
s_b = _mk_state(stream=True, rid="b")
_register(mgr, s_a)
_register(mgr, s_b)
text_a = "apple pie"
text_b = "blueberry muffin"
ids_a = self.tok.encode(text_a)
ids_b = self.tok.encode(text_b)
mgr.output_processor.handle_batch_output(
_batch_token_id_out(
["a", "b"],
decode_ids=[ids_a, ids_b],
finished_reasons=[
{"type": "stop", "matched": None},
{"type": "stop", "matched": None},
],
)
)
out_a = s_a.collector.take()
out_b = s_b.collector.take()
self.assertEqual(out_a["text"], text_a)
self.assertEqual(out_b["text"], text_b)
self.assertIsNot(s_a.inline_detokenizer, s_b.inline_detokenizer)
# ---------------------------------------------------------------------------
# Parity: inline text matches subprocess incremental_decode_batch output
# character-for-character for the same frame sequence.
# ---------------------------------------------------------------------------
def _run_subprocess_path(
tokenizer: Any,
rid: str,
frames: List[Dict[str, Any]],
) -> List[str]:
"""Drive ``incremental_decode_batch`` with a single-request sequence.
Returns the per-frame output_strs list.
``incremental_decode_batch`` aliases ``recv_obj.decode_ids[i]`` as the
per-request ``DecodeStatus.decode_ids`` list and extends it in place on
subsequent frames. Deep-copy each frame's ``decode_ids`` so the caller's
frame fixture isn't corrupted across runs.
"""
safe_frames = [
{**frame, "decode_ids": list(frame["decode_ids"])} for frame in frames
]
status: Dict[str, DecodeStatus] = {}
pieces: List[str] = []
for frame in safe_frames:
recv = _batch_token_id_out(
[rid],
decode_ids=[frame["decode_ids"]],
decoded_texts=[frame.get("decoded_text", "")],
read_offsets=[frame.get("read_offset", 0)],
finished_reasons=[frame.get("finished_reason")],
no_stop_trim=[frame.get("no_stop_trim", False)],
)
out_strs = incremental_decode_batch(tokenizer, status, recv)
pieces.append(out_strs[0])
return pieces
def _run_inline_path(
tokenizer: Any,
rid: str,
frames: List[Dict[str, Any]],
) -> List[str]:
"""Drive the inline receiver for a single request and collect the
incremental text fragments that would have appeared in the stream.
We read ``state.text`` before and after each frame and use the delta.
This matches what an OpenAI streaming client would observe.
Same deep-copy dance as ``_run_subprocess_path``: the state machine
aliases the frame's ``decode_ids`` list on the first frame and extends
it in place afterward, so shield the caller's fixture from mutation.
"""
safe_frames = [
{**frame, "decode_ids": list(frame["decode_ids"])} for frame in frames
]
mgr = _StubTokenizerManager(tokenizer, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid=rid)
_register(mgr, state)
pieces: List[str] = []
prev_text = ""
for idx, frame in enumerate(safe_frames):
seed_text = frame.get("decoded_text", "") if idx == 0 else ""
seed_offset = frame.get("read_offset", 0) if idx == 0 else 0
recv = _batch_token_id_out(
[rid],
decode_ids=[frame["decode_ids"]],
decoded_texts=[seed_text],
read_offsets=[seed_offset],
finished_reasons=[frame.get("finished_reason")],
no_stop_trim=[frame.get("no_stop_trim", False)],
)
mgr.output_processor.handle_batch_output(recv)
state.collector.take() # drain
pieces.append(state.text[len(prev_text) :])
prev_text = state.text
return pieces
class TestSubprocessVsInlineParity(unittest.TestCase):
"""For identical frame sequences the two paths must emit identical text.
The per-frame emits are compared byte-for-byte; the cumulative text is
compared too. Every drift between inline and subprocess behavior would
surface here.
"""
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def _assert_parity(self, frames: List[Dict[str, Any]]) -> None:
subp = _run_subprocess_path(self.tok, "r1", frames)
inline = _run_inline_path(self.tok, "r1", frames)
self.assertEqual(
inline,
subp,
msg=f"inline={inline!r} subprocess={subp!r}",
)
def test_parity_single_frame_ascii(self):
self._assert_parity(
[
{
"decode_ids": self.tok.encode("Hello, world!"),
"finished_reason": {"type": "stop", "matched": None},
}
]
)
def test_parity_per_token_ascii(self):
source = "streaming tokens one at a time"
ids = self.tok.encode(source)
frames = [{"decode_ids": [tid]} for tid in ids[:-1]]
frames.append(
{
"decode_ids": [ids[-1]],
"finished_reason": {"type": "stop", "matched": None},
}
)
self._assert_parity(frames)
def test_parity_two_frame_split(self):
source = "hello there friend"
ids = self.tok.encode(source)
mid = len(ids) // 2
self._assert_parity(
[
{"decode_ids": ids[:mid]},
{
"decode_ids": ids[mid:],
"finished_reason": {"type": "stop", "matched": None},
},
]
)
def test_parity_cjk_per_token(self):
# CJK exercises partial-UTF-8 deferral + find_printable_text.
source = "你好世界"
ids = self.tok.encode(source)
frames = [{"decode_ids": [tid]} for tid in ids[:-1]]
frames.append(
{
"decode_ids": [ids[-1]],
"finished_reason": {"type": "stop", "matched": None},
}
)
self._assert_parity(frames)
def test_parity_finish_with_matched_stop_string(self):
source = "keep this STOP drop this"
ids = self.tok.encode(source)
self._assert_parity(
[
{
"decode_ids": ids,
"finished_reason": {"type": "stop", "matched": "STOP"},
}
]
)
def test_parity_unfinished_streaming_does_not_trim(self):
# Streaming (no finish yet) must NOT apply stop trimming.
source = "prefix STOP more"
ids = self.tok.encode(source)
self._assert_parity(
[
{"decode_ids": ids[:3]},
{"decode_ids": ids[3:]}, # no finish — still streaming
]
)
def test_parity_emoji_per_token(self):
# 4-byte UTF-8 emoji per-token streaming. Exercises a different
# partial-byte shape than CJK: the emoji codepoint is split
# across ~4 byte-level BPE tokens (one per UTF-8 byte) and
# ``find_printable_text`` has to defer every intermediate frame
# until the full codepoint arrives. Any drift between the inline
# path's offset bookkeeping and the subprocess path's surfaces
# here because the defer-then-commit timing has to match
# byte-for-byte.
source = "a🌟b"
ids = self.tok.encode(source)
frames: List[Dict[str, Any]] = [{"decode_ids": [tid]} for tid in ids[:-1]]
frames.append(
{
"decode_ids": [ids[-1]],
"finished_reason": {"type": "stop", "matched": None},
}
)
self._assert_parity(frames)
def test_parity_finish_on_later_frame_with_prior_unfinished_commits(self):
# The highest-risk state-machine branch: finish arrives on a
# non-first frame after one or more unfinished commits have
# already landed. On the finished frame the commit block is
# skipped and ``trim_matched_stop`` runs on
# ``s.decoded_text + new_text`` using the offsets accumulated
# from prior commits. This mirrors the batch detokenizer coverage in
# ``test_finish_arrives_on_later_frame_applies_trim_at_finish_only``
# against the inline receiver path so any inline-specific drift shows up.
source = "Hello world the long sentence STOP tail text"
ids = self.tok.encode(source)
self.assertGreaterEqual(len(ids), 6)
# Pick the largest split whose decoded prefix is still
# STOP-free so frame 1 commits a clean prefix.
split: Optional[int] = None
for candidate in range(len(ids) - 1, 0, -1):
if "STOP" not in self.tok.decode(ids[:candidate]):
split = candidate
break
self.assertIsNotNone(split, "need a STOP-free prefix split")
self._assert_parity(
[
{"decode_ids": ids[:split]}, # unfinished commit
{
"decode_ids": ids[split:],
"finished_reason": {"type": "stop", "matched": "STOP"},
},
]
)
# ---------------------------------------------------------------------------
# Seed handling from BatchTokenIDOut.decoded_texts / read_offsets.
# ---------------------------------------------------------------------------
class TestSeedHandling(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def test_first_frame_honors_decoded_text_seed(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
seed = "<resume> "
ids = self.tok.encode("continuation")
recv = _batch_token_id_out(
["r1"],
decode_ids=[ids],
decoded_texts=[seed],
finished_reasons=[{"type": "stop", "matched": None}],
)
mgr.output_processor.handle_batch_output(recv)
out = state.collector.take()
self.assertEqual(out["text"], seed + self.tok.decode(ids))
def test_only_first_frame_seeds_detokenizer(self):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_state(stream=True, rid="r1")
_register(mgr, state)
ids_first = self.tok.encode("A")
ids_second = self.tok.encode("B")
# First frame with seed.
mgr.output_processor.handle_batch_output(
_batch_token_id_out(
["r1"],
decode_ids=[ids_first],
decoded_texts=["seed-"],
read_offsets=[0],
)
)
state.collector.take()
# Second frame passes a misleading decoded_texts which MUST be ignored
# because the detokenizer is already initialized.
mgr.output_processor.handle_batch_output(
_batch_token_id_out(
["r1"],
decode_ids=[ids_second],
decoded_texts=["ignored-"],
read_offsets=[999],
finished_reasons=[{"type": "stop", "matched": None}],
)
)
state.collector.take()
self.assertEqual(state.text, "seed-" + self.tok.decode(ids_first + ids_second))
# ---------------------------------------------------------------------------
# Logprob / meta field pass-through through the inline branch.
# ---------------------------------------------------------------------------
class _LogprobReqObj(_StubReqObj):
"""Request object that asks for output logprobs in a given dialect so
``_handle_batch_output`` invokes ``convert_logprob_style`` on the recv_obj.
``fmt="vllm"`` sets ``sampling_params["logprobs"]=0`` (the output processor
renders the vLLM ``logprobs`` dict). ``fmt="sglang"`` leaves sampling_params
without ``logprobs`` so the SGLang ``return_logprob`` flag drives the
tuple-list rendering.
"""
def __init__(
self,
*,
rid: str = "r1",
fmt: str = "vllm",
) -> None:
super().__init__(stream=True, return_logprob=True, rid=rid)
self.sampling_params = {"logprobs": 0} if fmt == "vllm" else {}
self.logprob_format = None # auto: match the request dialect
def _mk_logprob_state(*, rid: str = "r1", fmt: str = "vllm") -> ReqState:
return ReqState(
RequestOutputCollector(),
False,
__import__("asyncio").Event(),
_LogprobReqObj(rid=rid, fmt=fmt),
created_time=0.0,
)
class TestInlineLogprobPassThrough(unittest.TestCase):
"""Verify the sampled-token output logprob arrays on a ``BatchTokenIDOut``
flow through the inline branch of ``_handle_batch_output`` into ``meta_info``
— in BOTH dialects, selected per request, from the same wire arrays.
"""
@classmethod
def setUpClass(cls) -> None:
cls.tok = _gpt2_tokenizer()
def _run(self, fmt):
mgr = _StubTokenizerManager(self.tok, enable_inline_detokenizer=True)
state = _mk_logprob_state(rid="r1", fmt=fmt)
_register(mgr, state)
recv = _batch_token_id_out(
["r1"],
decode_ids=[self.tok.encode("Hello")],
finished_reasons=[{"type": "stop", "matched": None}],
input_token_logprobs_val=[[]],
input_token_logprobs_idx=[[]],
output_token_logprobs_val=[[-0.5, -0.6]],
output_token_logprobs_idx=[[1, 2]],
)
mgr.output_processor.handle_batch_output(recv)
return state.collector.take()["meta_info"]
def test_vllm_format(self):
meta = self._run("vllm")
# vLLM shape: "logprobs" is a list[dict[int, Logprob]], one per token.
self.assertIn("logprobs", meta)
self.assertNotIn("output_token_logprobs", meta)
self.assertEqual(len(meta["logprobs"]), 2)
self.assertEqual(meta["logprobs"][0][1].logprob, -0.5)
self.assertEqual(meta["logprobs"][0][1].rank, 0)
self.assertEqual(meta["logprobs"][1][2].logprob, -0.6)
self.assertAlmostEqual(meta["cumulative_logprob"], -1.1)
def test_sglang_format(self):
meta = self._run("sglang")
# SGLang shape: "output_token_logprobs" is a list of (val, idx, text)
# tuples (text None when not decoding); no vLLM "logprobs" key.
self.assertIn("output_token_logprobs", meta)
self.assertNotIn("logprobs", meta)
self.assertEqual([e[0] for e in meta["output_token_logprobs"]], [-0.5, -0.6])
self.assertEqual([e[1] for e in meta["output_token_logprobs"]], [1, 2])
if __name__ == "__main__":
unittest.main(verbosity=2)