[Bugfix] Add opt-in MiniCPM-o 4.5 Stage-0 sliding window (#7631)

Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Signed-off-by: 0z5a <dezhen.lu@uni-tuebingen.de>
Co-authored-by: 0z5a <dezhen.lu@uni-tuebingen.de>
This commit is contained in:
0z5aand0z5a authored and GitHub committed 2026-09-21 18:34:22 -07:00
1 parent c11596c637
commit 8bc0c38b90
14 files changed
+1230 -12

No files matched your search

@@ -255,6 +255,8 @@ source_file_dependencies:
- *minicpmo_4_5_duplex
- tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
- tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- tests/e2e/online_serving/test_minicpmo_4_5_window.py
- tests/e2e/online_serving/helpers/minicpmo_window_e2e.py
- tests/e2e/online_serving/test_duplex_client_live.py
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
+1 -1
View File
@@ -130,7 +130,7 @@ steps:
source_file_dependencies: omni_minicpmo_4_5_duplex_function
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py tests/e2e/online_serving/test_minicpmo_4_5_window.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Test"
@@ -69,6 +69,39 @@ def _make_update(prompt_token_ids: list[int] | None = None) -> StreamingUpdate:
)
def _make_minicpm_window_update(
*,
seq: int,
mode: str,
high: int = 15,
low: int = 10,
context_max_units: int = 1,
) -> StreamingUpdate:
update = _make_update([0] * 8)
update.model_intermediate_buffer = {
"duplex": {
"data_plane": True,
"seq": seq,
"runtime_config": {
"duplex_scheduler_token_id": 0,
"duplex_first_append_context_tokens": 3,
"duplex_window_prefix_tokens": 2,
"duplex_window_suffix_token_ids": [3],
"duplex_window_previous_marker_token_ids": [70, 71],
"duplex_window_special_token_ids": [99],
"duplex_window_config": {
"sliding_window_mode": mode,
"basic_window_high_tokens": high,
"basic_window_low_tokens": low,
"context_previous_max_tokens": 4,
"context_max_units": context_max_units,
},
},
}
}
return update
def _make_talker_adapter(
*,
max_model_len: int = 100,
@@ -510,6 +543,225 @@ def test_stage0_streaming_update_keeps_all_computed_tokens_without_placeholder()
assert sched._new_prompt_len_snapshot[session.request_id] == 2
def test_stage0_basic_window_rebuilds_below_low_watermark() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids.clear()
session._all_token_ids.extend(session.prompt_token_ids)
session.num_prompt_tokens = 9
session.append_output_token_ids([40])
session.num_computed_tokens = 10
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
update = _make_minicpm_window_update(seq=2, mode="basic")
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [0] * 9
assert session.num_computed_tokens == 0
assert update.model_intermediate_buffer["meta"]["replace_streaming_prompt"] is True
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan == {
"completed_token_ids": [40],
"replace": True,
"mode": "basic",
"drop_units": 1,
"dropped_tokens": 9,
"previous_token_ids": [],
"previous_marker_token_ids": [70, 71],
"replacement_prompt_len": 9,
}
sched._free_request_blocks.assert_called_once_with(session)
def test_stage0_context_window_compacts_dropped_speech() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids.clear()
session._all_token_ids.extend(session.prompt_token_ids)
session.num_prompt_tokens = 9
session.append_output_token_ids([40])
session.num_computed_tokens = 10
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
first = _make_minicpm_window_update(seq=2, mode="context")
sched._update_request_as_session(session, first)
assert session.num_prompt_tokens == 18
assert first.model_intermediate_buffer["duplex"]["stage0_window"] == {"completed_token_ids": [40]}
session.append_output_token_ids([50])
session.num_computed_tokens = 19
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
second = _make_minicpm_window_update(seq=3, mode="context")
sched._update_request_as_session(session, second)
assert session.prompt_token_ids == [0] * 21
plan = second.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["completed_token_ids"] == [50]
assert plan["drop_units"] == 1
assert plan["previous_token_ids"] == [40]
# The worker embeds this exact marker, not a re-tokenized one, so the
# previous region is len(marker) + len(previous).
assert plan["previous_marker_token_ids"] == [70, 71]
assert plan["replacement_prompt_len"] == 21
assert getattr(session, "_minicpmo45_window_previous_len") == 3
@pytest.mark.parametrize("cleared_outputs", [False, True])
@pytest.mark.parametrize("in_flight", [0, 1])
def test_stage0_window_uses_confirmed_span_and_terminator(cleared_outputs, in_flight) -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids[:] = session.prompt_token_ids
session.num_prompt_tokens = 9
session.append_output_token_ids([40, 99])
session.num_computed_tokens = 10 + in_flight
session.num_output_placeholders = in_flight
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
if cleared_outputs:
# A stop without a queued append clears this before the next update.
session._output_token_ids.clear()
update = _make_minicpm_window_update(seq=2, mode="basic")
sched._update_request_as_session(session, update)
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["completed_token_ids"] == [40]
assert plan["completed_terminator_token_id"] == 99
assert plan["replacement_prompt_len"] == 9
assert plan["dropped_tokens"] == 9
def test_stage0_window_rebuild_that_overflows_max_model_len_finishes_the_session() -> None:
"""A rebuilt window prompt is bounded by the client's window settings, not
by the model: a unit carrying camera frames is hundreds of tokens, and a
``context`` window adds the ``previous`` region on top. The replacement
branch must therefore check ``replacement_prompt_len`` against
``max_model_len - sample_room`` and finish the session with
``context_length_exceeded``, exactly like a plain extension."""
sched = _make_live_session_scheduler(max_model_len=21)
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids[:] = [0] * 9
session.num_prompt_tokens = 9
session._output_token_ids[:] = [40]
session._all_token_ids.append(40)
session.num_computed_tokens = 10
session.num_output_placeholders = 0
_park_session(sched, session)
# The parked append state above is the frame before this update: one
# confirmed output token, fully computed.
session._output_token_ids[:] = [40]
session._all_token_ids[:] = [*session.prompt_token_ids, 40]
session.num_computed_tokens = 10
session.num_in_flight_tokens = 0
sched.num_sampled_tokens_per_step = 1
# The replacement plan rebuilds 21 tokens (retained context plus the
# append) and one step samples one more, so 21 leaves no room.
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 20
original_prompt = list(session.prompt_token_ids)
sched._update_request_as_session(session, update)
assert update.model_intermediate_buffer["duplex"]["stage0_window"]["replacement_prompt_len"] == 21
assert session.prompt_token_ids == original_prompt
assert session.num_prompt_tokens == 9
assert session.status == RequestStatus.FINISHED_ERROR
assert session.request_id not in sched.requests
assert sched.finished_req_ids == {session.request_id}
sched._free_request_blocks.assert_called_once_with(session)
client_index, reason = sched._streaming_context_overflow[session.request_id]
assert client_index == session.client_index
assert reason.startswith("context_length_exceeded: ")
assert "21 tokens" in reason and "max_model_len 21" in reason
engine_core_outputs = _run_idle_step(sched)
(output,) = engine_core_outputs[session.client_index].outputs
assert output.request_id == session.request_id
assert output.finish_reason == FinishReason.ERROR
assert output.stop_reason == reason
def test_stage0_window_rebuild_that_leaves_room_to_sample_replaces_the_prompt() -> None:
"""One slot above the plan fits: the replacement applies through the normal
replacement path."""
sched = _make_scheduler(stage_id=0)
sched.max_model_len = 25
sched.num_sampled_tokens_per_step = 1
session = _make_request()
session.prompt_token_ids = [0] * 9
session._all_token_ids[:] = session.prompt_token_ids
session.num_prompt_tokens = 9
session.append_output_token_ids([40])
session.num_computed_tokens = 10
session.num_output_placeholders = 0
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 20
sched._update_request_as_session(session, update)
assert update.model_intermediate_buffer["meta"]["replace_streaming_prompt"] is True
assert update.model_intermediate_buffer["duplex"]["stage0_window"]["replacement_prompt_len"] == 21
assert session.prompt_token_ids == [0] * 21
assert session.num_prompt_tokens == 21
assert session.num_computed_tokens == 0
assert session.status == RequestStatus.WAITING
sched._free_request_blocks.assert_called_once_with(session)
assert not getattr(sched, "_streaming_context_overflow", {})
def test_stage0_window_open_start_zero_is_not_replaced_by_the_context_reserve() -> None:
"""A recorded ``open_start`` of 0 is a legitimate empty context prefix. The
fallback must be an explicit ``is None`` check: treating 0 as missing
substitutes the context reserve, counts the suffix twice, and the worker's
rebuild-length check raises on the first replacement."""
session = SimpleNamespace(
num_prompt_tokens=9,
_all_token_ids=[0] * 9 + [40],
num_computed_tokens=10,
num_output_placeholders=0,
_minicpmo45_window_open_start=0,
)
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 21
assert OmniARScheduler._prepare_minicpmo45_stage0_window(
session, update, segment_output_ids=[40], completed_terminator=3
)
# open_start stays 0, so the reported unit spans the whole 12-token
# boundary instead of the 9 the context reserve would fold in.
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["dropped_tokens"] == 12
assert plan["replacement_prompt_len"] == 19
def test_stage0_window_open_start_falls_back_to_the_context_reserve_when_unset() -> None:
session = SimpleNamespace(
num_prompt_tokens=9,
_all_token_ids=[0] * 9 + [40],
num_computed_tokens=10,
num_output_placeholders=0,
)
update = _make_minicpm_window_update(seq=2, mode="basic")
update.prompt_token_ids = [0] * 21
assert OmniARScheduler._prepare_minicpmo45_stage0_window(
session, update, segment_output_ids=[40], completed_terminator=3
)
# fallback open_start is preserve_len 3 from duplex_first_append_context_tokens,
# so the unit is three tokens shorter.
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
assert plan["dropped_tokens"] == 9
assert plan["replacement_prompt_len"] == 22
def test_explicit_streaming_payload_replaces_placeholder_prompt() -> None:
sched = _make_scheduler(stage_id=1)
sched.chunk_transfer_adapter = SimpleNamespace(
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Drive complete native duplex turns while Stage-0 repeatedly rebuilds KV."""
from __future__ import annotations
import asyncio
import base64
from pathlib import Path
from vllm_omni.clients.duplex import DuplexClient, EventCollector, read_pcm16_wav, wait_for_condition
from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
async def run_window_turn(
*,
url: str,
model: str,
input_wav: Path,
mode: str,
ref_audio: Path | None = None,
repeats: int = 2,
video_frames: list[str] | None = None,
buffered_flush: bool = False,
timeout_s: float = 240.0,
) -> dict[str, object]:
"""Use aggressive limits to exercise multiple windows in one audio turn."""
pcm = read_pcm16_wav(input_wav) * repeats
collector = EventCollector()
reference = (
"data:audio/wav;base64," + base64.b64encode(ref_audio.read_bytes()).decode("ascii")
if ref_audio is not None
else None
)
config = create_duplex_session_config(
modalities=("text", "audio") if ref_audio is not None else ("text",),
ref_audio=reference,
temperature=0.0,
extra_body={
"sliding_window_mode": mode,
"context_max_units": 1,
"context_previous_max_tokens": 16,
"basic_window_high_tokens": 64,
"basic_window_low_tokens": 32,
},
)
client = DuplexClient(
url, model=model, config=config, reconnect=None, heartbeat_interval_s=None, handshake_timeout_s=timeout_s
)
async with client:
reader = asyncio.create_task(collector.consume(client))
collector.add({"type": "session.created", "session": client.session_info})
try:
frames_sent = await client.stream_pcm(
pcm,
chunk_ms=2500 if buffered_flush else 200,
realtime=not buffered_flush,
is_speech=True,
video_frames=video_frames,
)
await client.commit(final=True)
await wait_for_condition(
lambda: collector.count("input_audio_buffer.committed") > 0 or bool(collector.errors()),
timeout_s=timeout_s,
label="final input commit",
)
await wait_for_condition(
lambda: collector.count("response.done") > 0 or bool(collector.errors()),
timeout_s=timeout_s,
label="completed windowed response",
)
# Final execution follows the commit acknowledgement asynchronously.
await asyncio.sleep(3.0)
await client.close(timeout_s=timeout_s)
await asyncio.wait_for(reader, timeout=5.0)
finally:
if not reader.done():
reader.cancel()
await asyncio.gather(reader, return_exceptions=True)
return {
"created": collector.count("session.created"),
"committed": collector.count("input_audio_buffer.committed"),
"done": collector.count("response.done"),
"closed": collector.count("session.closed"),
"errors": collector.errors(),
"audio_bytes": len(collector.audio_bytes()),
"frames_sent": frames_sent,
"input_seconds": len(pcm) / 32000,
}
@@ -0,0 +1,88 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Real-model regression for the scheduler-owned Thinker sliding window."""
import asyncio
import pytest
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS,
duplex_camera_frames,
realtime_url,
resolve_ref_audio,
validated_input_wav,
)
from tests.e2e.online_serving.helpers.minicpmo_window_e2e import run_window_turn
from tests.helpers.mark import hardware_test
pytestmark = [pytest.mark.omni, pytest.mark.advanced_model]
def _assert_complete(result, *, require_audio=False):
assert result["created"] == 1
assert result["committed"] > 0
assert result["done"] > 0
assert result["closed"] == 1
assert result["errors"] == []
if require_audio:
assert result["audio_bytes"] > 0
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
@pytest.mark.parametrize("mode,reference", [("basic", False), ("context", False), ("context", True)])
def test_window_rebuild_and_next_session(omni_server, mode, reference):
# Two independent sessions exercise admission after final-unit execution.
for _ in range(2):
result = asyncio.run(
run_window_turn(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=validated_input_wav(),
mode=mode,
ref_audio=resolve_ref_audio() if reference else None,
)
)
_assert_complete(result, require_audio=reference)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
@pytest.mark.parametrize("mode,camera", [("basic", False), ("context", False), ("context", True)])
def test_window_continuous_input(omni_server, tmp_path, mode, camera):
# 24 repeats cover over two minutes and many canonical KV replacements.
frames = duplex_camera_frames(seconds=4, cache_dir=tmp_path / "camera") if camera else None
result = asyncio.run(
run_window_turn(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=validated_input_wav(),
mode=mode,
repeats=24,
ref_audio=resolve_ref_audio(),
video_frames=frames,
)
)
_assert_complete(result, require_audio=True)
assert isinstance(result["input_seconds"], (int, float)) and result["input_seconds"] > 120
if camera:
assert isinstance(result["frames_sent"], int) and result["frames_sent"] >= 120
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
@pytest.mark.parametrize("mode", ["off", "basic", "context"])
def test_window_buffered_flush(omni_server, mode):
# Burst appends cross processor chunk boundaries and leave a final tail.
result = asyncio.run(
run_window_turn(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=validated_input_wav(),
mode=mode,
ref_audio=resolve_ref_audio(),
buffered_flush=True,
)
)
_assert_complete(result, require_audio=True)
@@ -0,0 +1,117 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import base64
from types import SimpleNamespace
import pytest
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.model_executor.models.minicpmo_4_5.duplex import plugin as module
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _plugin():
return module.MiniCPMO45DuplexPlugin(lambda *args: None)
@pytest.mark.asyncio
async def test_window_configuration_is_prepared_by_plugin(monkeypatch):
tokenizer = SimpleNamespace(
all_special_ids=[90],
unk_token_id=-1,
eos_token_id=99,
encode=lambda text, add_special_tokens=False: [ord(char) for char in text],
convert_tokens_to_ids=lambda token: {"<|listen|>": 91}.get(token, -1),
)
monkeypatch.setattr(module, "_load_tokenizer", lambda config: tokenizer)
config = DuplexSessionConfig(
modalities=("text",),
extra_body={
"sliding_window_mode": "basic",
"basic_window_high_tokens": 120,
"basic_window_low_tokens": 80,
},
)
runtime = await _plugin().prepare_runtime_config(config, model_config=None)
assert runtime["duplex_window_config"]["sliding_window_mode"] == "basic"
assert "sliding_window_mode" not in config.extra_body
assert runtime["duplex_window_prefix_tokens"] > 0
assert runtime["duplex_window_suffix_token_ids"]
assert runtime["duplex_window_previous_marker_token_ids"]
@pytest.mark.asyncio
async def test_invalid_window_configuration_is_rejected():
config = DuplexSessionConfig(
modalities=("text",),
extra_body={
"basic_window_high_tokens": 80,
"basic_window_low_tokens": 80,
},
)
with pytest.raises(module.MiniCPMO45ClientRuntimeConfigError) as exc:
await _plugin().prepare_runtime_config(config, model_config=None)
assert exc.value.code == "invalid_sliding_window_config"
def test_window_update_cannot_change_created_configuration():
config = DuplexSessionConfig(extra_body={"sliding_window_mode": "basic"})
with pytest.raises(module.MiniCPMO45ClientRuntimeConfigError) as exc:
_plugin().runtime_config_for_update(config, {"duplex_window_config": {"sliding_window_mode": "off"}})
assert exc.value.code == "sliding_window_update_unsupported"
def test_window_internal_configuration_is_server_owned():
with pytest.raises(module.MiniCPMO45ClientRuntimeConfigError):
_plugin().validate_client_extra_body({"duplex_window_config": {}})
@pytest.mark.parametrize("final", [False, True])
@pytest.mark.parametrize("seq,samples,expected", [(1, 32000, 16), (1, 48000, 28), (2, 16000, 13)])
def test_first_and_final_append_reserve_exact_window_input(seq, samples, expected, final):
prompt = module.build_duplex_data_plane_prompt(
request_id="window-request",
fence=DuplexFence("sid", turn_id=1),
session_config={},
runtime_config={"duplex_first_append_context_tokens": 5},
seq=seq,
turn_seq=seq,
payload={
"audio": base64.b64encode(bytes(samples * 4)).decode(),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
},
final=final,
)
assert len(prompt["prompt_token_ids"]) == expected
@pytest.mark.parametrize("seq", [1, 2, 3])
def test_final_exact_chunk_append_reserves_one_unit_not_two(seq):
"""A final append whose audio is already an exact number of chunks must
reserve exactly what Stage0 feeds: one unit per chunk (``<unit>`` plus its
audio) and the closure pair for every unit after the first. Serving pads
the final residual itself, so the old extra 12-slot silent-unit
reservation only put 12 pad positions ahead of the audio in the KV."""
def _budget(*, seq, final):
prompt = module.build_duplex_data_plane_prompt(
request_id="final-request",
fence=DuplexFence("sid", turn_id=1),
session_config={},
runtime_config={"duplex_first_append_context_tokens": 0},
seq=seq,
turn_seq=seq,
payload={
"audio": base64.b64encode(bytes(16000 * 4)).decode(),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
},
final=final,
)
return len(prompt["prompt_token_ids"])
assert _budget(seq=seq, final=True) == _budget(seq=seq, final=False)
@@ -142,6 +142,37 @@ def test_native_duplex_speak_segment_reaches_split_talker() -> None:
assert info["duplex"]["turn_id"] == 7
@pytest.mark.parametrize("folded_decisions", [0, 1, 2])
@pytest.mark.parametrize("bos_in_prompt", [False, True])
def test_native_duplex_tts_bos_aligns_after_window_rebuild(folded_decisions, bos_in_prompt) -> None:
prompt_ids = [101] * 10
output_ids = [9303] * folded_decisions + [9301, 21, 22, 9308]
if bos_in_prompt:
prompt_ids[-1] = 9301
output_ids = [21, 22, 9308]
rows = len(prompt_ids) + len(output_ids) - folded_decisions
latent = torch.arange(rows * 4, dtype=torch.float32).reshape(rows, 4)
source = _output(
prompt_ids=prompt_ids,
output_ids=output_ids,
latent=latent,
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": {
"tts_bos_token_id": 9301,
"tts_eos_token_id": 9302,
"listen_token_id": 9303,
"speak_token_id": 9304,
"chunk_eos_token_id": 9308,
},
},
)
converted = llm2tts([source], prompt=[{}], _streaming_context=SimpleNamespace(bridge_states={}))[0]
info = converted["model_intermediate_buffer"]
assert info["ids"]["tts"] == [21, 22]
torch.testing.assert_close(torch.as_tensor(info["hidden_states"]["tts"]), latent[-3:-1])
def test_native_duplex_continuation_appends_only_new_talker_condition() -> None:
prompt_ids = [101, 102]
token_ids = {
+170
View File
@@ -848,6 +848,176 @@ def test_minicpmo_stage0_data_plane_prefill_matches_official_unit_format():
assert result["prompt_suffix_len"] == 0
@pytest.mark.parametrize("mode", [None, "off", "basic", "context"])
def test_minicpmo_stage0_history_collection_starts_with_session_config(mode):
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import _MiniCPMO45Stage0SessionState
runtime = _stage0_vision_runtime()
state = _MiniCPMO45Stage0SessionState(session_id="history-lifecycle")
config = {} if mode is None else {"duplex_window_config": {"sliding_window_mode": mode}}
runtime._prepare_session_context(state, {}, runtime_config=config)
for seq in range(1, 101):
runtime._stage_prefill_embeddings_only(state, np.zeros(4, dtype=np.float32), seq=seq)
enabled = mode in {"basic", "context"}
assert state.window_enabled is enabled
assert len(state.window_units) == (99 if enabled else 0)
assert (state.pending_window_unit is not None) is enabled
assert state.pending_window_generated_tokens == []
@pytest.mark.parametrize("mode", ["basic", "context"])
@pytest.mark.parametrize("buffered", [False, True])
def test_minicpmo_stage0_multi_chunk_append_matches_scheduler_window(mode, buffered):
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import _MiniCPMO45Stage0SessionState
runtime = _stage0_vision_runtime()
state = _MiniCPMO45Stage0SessionState(session_id="multi-chunk-window", window_enabled=True)
if buffered:
# Keep the tiny fake processor's initial chunk unpadded to model carry.
runtime._pad_first_audio_chunk_if_needed = lambda *args: None
assert runtime._stage_prefill_embeddings_only(state, np.zeros(2, dtype=np.float32), seq=0)["success"] is False
first = runtime._stage_prefill_embeddings_only(
state, np.zeros(6 if buffered else 8, dtype=np.float32), seq=1, final=buffered
)
assert first["input_token_ids"] == [1, 11, 2, 1, 11]
assert state.window_units == []
assert state.pending_window_unit.token_ids == first["input_token_ids"]
session = SimpleNamespace(num_prompt_tokens=5)
replaced = False
for seq in (2, 3):
info = {
"duplex": {
"data_plane": True,
"seq": seq,
"runtime_config": {
"duplex_window_config": {
"sliding_window_mode": mode,
"basic_window_high_tokens": 1,
"basic_window_low_tokens": 2,
"context_max_units": 1,
"context_previous_max_tokens": 16,
},
"duplex_window_previous_marker_token_ids": [201, 5],
},
}
}
update = SimpleNamespace(prompt_token_ids=[3, 2, 1, 11], model_intermediate_buffer=info)
planned = OmniARScheduler._prepare_minicpmo45_stage0_window(
session, update, segment_output_ids=[44], completed_terminator=3
)
result = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), seq=seq, stage0_window=info["duplex"]["stage0_window"]
)
assert result["stage0_window_replaced"] is planned
if planned:
assert result["num_input_tokens"] == len(update.prompt_token_ids)
replaced = True
break
session.num_prompt_tokens += 1 + len(update.prompt_token_ids)
assert replaced
def test_minicpmo_stage0_basic_window_rebuilds_from_retained_units():
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import (
_MiniCPMO45Stage0SessionState,
)
runtime = _stage0_vision_runtime()
state = _MiniCPMO45Stage0SessionState(session_id="sid-stage0-basic-window", window_enabled=True)
first = runtime._stage_prefill_embeddings_only(state, np.zeros(4, dtype=np.float32), seq=1)
assert first["input_token_ids"] == [1, 11]
state.pending_window_generated_tokens.append(5)
state.pending_terminator_token = 3
rebuilt = runtime._stage_prefill_embeddings_only(
state,
np.zeros(4, dtype=np.float32),
seq=2,
stage0_window={
"replace": True,
"mode": "basic",
"drop_units": 1,
"replacement_prompt_len": 2,
},
)
assert rebuilt["stage0_window_replaced"] is True
assert rebuilt["input_token_ids"] == [1, 11]
assert state.window_units == []
def test_minicpmo_stage0_context_window_inserts_previous_before_suffix():
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import (
_MiniCPMO45Stage0SessionState,
)
runtime = _stage0_vision_runtime()
state = _MiniCPMO45Stage0SessionState(session_id="sid-stage0-context-window", window_enabled=True)
prefix = runtime._embed_token(200)
suffix = runtime._embed_token(202)
state.context_embeds = [prefix, suffix]
state.context_token_ids = [200, 202]
state.context_prefix_embeds = [prefix]
state.context_prefix_token_ids = [200]
state.context_suffix_embeds = [suffix]
state.context_suffix_token_ids = [202]
first = runtime._stage_prefill_embeddings_only(state, np.zeros(4, dtype=np.float32), seq=1)
assert first["input_token_ids"] == [200, 202, 1, 11]
state.pending_window_generated_tokens.append(42)
state.pending_terminator_token = 3
rebuilt = runtime._stage_prefill_embeddings_only(
state,
np.zeros(4, dtype=np.float32),
seq=2,
stage0_window={
"replace": True,
"mode": "context",
"drop_units": 1,
"previous_token_ids": [42],
"previous_marker_token_ids": [201, 5],
"replacement_prompt_len": 7,
},
)
assert rebuilt["input_token_ids"] == [200, 201, 5, 42, 202, 1, 11]
assert rebuilt["num_input_tokens"] == 7
@pytest.mark.parametrize("pending_terminator", [None, 3, 99])
def test_minicpmo_stage0_window_uses_accepted_output_not_async_sampler_history(pending_terminator):
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import (
_MiniCPMO45Stage0SessionState,
)
runtime = _stage0_vision_runtime()
runtime._stage_audio_embeddings = lambda *args, **kwargs: torch.zeros((10, 2))
state = _MiniCPMO45Stage0SessionState(session_id="sid-accepted-window", window_enabled=True)
state.context_prefix_token_ids = [200] * 7
state.context_suffix_token_ids = [202]
state.context_token_ids = [*state.context_prefix_token_ids, 202]
state.context_prefix_embeds = [runtime._embed_token(token_id) for token_id in state.context_prefix_token_ids]
state.context_suffix_embeds = [runtime._embed_token(202)]
state.context_embeds = [*state.context_prefix_embeds, *state.context_suffix_embeds]
runtime._stage_prefill_embeddings_only(state, np.zeros(4, dtype=np.float32), seq=1)
for seq in (2, 3):
state.pending_window_generated_tokens = [3, 40, 41]
state.pending_terminator_token = pending_terminator
plan = {"completed_token_ids": [], "completed_terminator_token_id": 3}
if seq == 3:
plan.update(replace=True, mode="context", drop_units=1, replacement_prompt_len=32)
result = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), seq=seq, stage0_window=plan
)
assert result["num_input_tokens"] == 32
assert [len(unit.token_ids) for unit in state.window_units] == [13]
assert len(state.pending_window_unit.token_ids) == 11
assert 40 not in result["input_token_ids"]
assert 41 not in result["input_token_ids"]
def _stage0_vision_runtime():
import torch
+206 -3
View File
@@ -848,6 +848,16 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
req_id = session.request_id
self._new_prompt_len_snapshot[req_id] = len(update.prompt_token_ids)
outstanding_async_tokens = getattr(session, "num_output_placeholders", 0)
# Use the same confirmed span that upstream preserves when extending
# a session. The segment output list is cleared on a resumable stop,
# and, for an already queued append, can still include its terminator.
confirmed_end = session.num_computed_tokens - outstanding_async_tokens
segment_output_ids = list(session._all_token_ids[session.num_prompt_tokens : confirmed_end])
completed_terminator = (
session._all_token_ids[confirmed_end]
if session.num_prompt_tokens <= confirmed_end < len(session._all_token_ids)
else None
)
# Seed the stale share in SCHEDULED-token units (see the segment-stop
# site in update_from_output): num_in_flight_tokens matches what each
# pre-replacement frame will drain, so the counter reaches exactly
@@ -894,6 +904,23 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.log_stats:
session.record_event(EngineCoreEventType.QUEUED)
return
if stage_id == 0 and self._prepare_minicpmo45_stage0_window(
session,
update,
segment_output_ids=segment_output_ids,
completed_terminator=completed_terminator,
):
# The rebuilt prompt is bounded by the client's window settings,
# not by the model (a camera unit is hundreds of tokens), so it
# needs the same max_model_len check as a plain extension. The
# plan replaces the whole prompt, so the replacement length is the
# projection, not the session's current prompt plus an extension.
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
if self._streaming_update_overflows(session, update, projected_len=plan["replacement_prompt_len"]):
return
self._release_replaced_streaming_prompt_cache(session)
self._replace_streaming_session(session, update)
return
streaming_prompt_payload = next(
(
info
@@ -962,11 +989,178 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if hasattr(update, "model_intermediate_buffer"):
session.model_intermediate_buffer = update.model_intermediate_buffer
@staticmethod
def _prepare_minicpmo45_stage0_window(
session: Request,
update: StreamingUpdate,
*,
segment_output_ids: list[int],
completed_terminator: int | None = None,
) -> bool:
"""Plan an official-style MiniCPM Stage-0 window at unit boundaries.
vLLM owns a paged KV cache, so deleting a middle span and rotating the
retained K tensors in place is not a safe model hook. Instead, record
completed unit lengths in the scheduler and request a full prompt
replacement when a watermark fires. The worker rebuilds matching
embeddings from its unit history, which recomputes RoPE at the new
contiguous positions.
"""
info = getattr(update, "model_intermediate_buffer", None)
if not isinstance(info, dict):
return False
duplex = info.get("duplex")
if not isinstance(duplex, dict) or duplex.get("data_plane") is not True:
return False
runtime_config = duplex.get("runtime_config")
runtime_config = runtime_config if isinstance(runtime_config, dict) else {}
window = runtime_config.get("duplex_window_config")
if not isinstance(window, dict):
return False
mode = window.get("sliding_window_mode", "off")
if mode == "off":
return False
if mode not in {"basic", "context"}:
return False
try:
seq = int(duplex.get("seq", 0) or 0)
preserve_len = int(runtime_config.get("duplex_first_append_context_tokens", 0) or 0)
except (TypeError, ValueError):
return False
if seq <= 1:
return False
# The sampled terminator is discarded by the normal session update and
# re-injected at the head of this append, followed by </unit>.
# generated_ids contains only the confirmed output span retained by
# the streaming update, excluding its final uncomputed sample.
generated_ids = segment_output_ids
# Sampler-side history can contain speculative or stale async output.
# The worker must rebuild from the same accepted ids used for lengths.
duplex["stage0_window"] = {"completed_token_ids": list(generated_ids)}
if completed_terminator is not None:
duplex["stage0_window"]["completed_terminator_token_id"] = int(completed_terminator)
base_len = int(getattr(session, "num_prompt_tokens", 0) or 0) + len(generated_ids)
boundary = base_len + 2
# A legitimate open_start of 0 (an empty context prefix) must not fall
# back to preserve_len, which would double-count the suffix below.
recorded_open_start = getattr(session, "_minicpmo45_window_open_start", None)
open_start = preserve_len if recorded_open_start is None else int(recorded_open_start)
unit_len = boundary - open_start
if unit_len <= 0:
return False
special_ids = {
int(token_id)
for token_id in runtime_config.get("duplex_window_special_token_ids", ())
if isinstance(token_id, int)
}
units = list(getattr(session, "_minicpmo45_window_units", ()))
units.append(
{
"length": unit_len,
"generated_token_ids": [int(token_id) for token_id in generated_ids if token_id not in special_ids],
}
)
projected_len = base_len + len(update.prompt_token_ids)
drop_count = 0
dropped_len = 0
if mode == "basic":
try:
high = int(window.get("basic_window_high_tokens", 8000))
low = int(window.get("basic_window_low_tokens", 6000))
except (TypeError, ValueError):
return False
if projected_len > high:
while units and projected_len - dropped_len > low:
dropped_len += int(units[drop_count]["length"])
drop_count += 1
if drop_count >= len(units):
break
else:
try:
max_units = int(window.get("context_max_units", 24))
previous_max = int(window.get("context_previous_max_tokens", 500))
except (TypeError, ValueError):
return False
drop_count = max(0, len(units) - max_units)
dropped_len = sum(int(unit["length"]) for unit in units[:drop_count])
previous = list(getattr(session, "_minicpmo45_window_previous_token_ids", ()))
for unit in units[:drop_count]:
previous.extend(unit["generated_token_ids"])
if len(previous) > previous_max:
previous = previous[-previous_max:]
marker = [
int(token_id)
for token_id in runtime_config.get("duplex_window_previous_marker_token_ids", ())
if isinstance(token_id, int)
]
previous_with_marker = marker + previous if previous else []
old_previous_len = int(getattr(session, "_minicpmo45_window_previous_len", 0) or 0)
session._minicpmo45_window_previous_token_ids = previous
session._minicpmo45_window_previous_len = len(previous_with_marker)
projected_len += len(previous_with_marker) - old_previous_len
retained_units = units[drop_count:]
session._minicpmo45_window_units = retained_units
if drop_count == 0:
session._minicpmo45_window_open_start = boundary
return False
replacement_len = projected_len - dropped_len
if replacement_len <= 0:
return False
scheduler_token_id = runtime_config.get("duplex_scheduler_token_id", 0)
try:
scheduler_token_id = max(0, int(scheduler_token_id))
except (TypeError, ValueError):
scheduler_token_id = 0
update.prompt_token_ids = [scheduler_token_id] * replacement_len
meta = info.setdefault("meta", {})
if isinstance(meta, dict):
meta["replace_streaming_prompt"] = True
previous_ids = list(getattr(session, "_minicpmo45_window_previous_token_ids", ()))
# The worker embeds the marker ids the window plan carries: it must not
# re-tokenize the marker with its own tokenizer, which could differ
# from the ids this length is computed from.
marker_ids = [
int(token_id)
for token_id in runtime_config.get("duplex_window_previous_marker_token_ids", ())
if isinstance(token_id, int)
]
duplex["stage0_window"] = {
"completed_token_ids": list(generated_ids),
"replace": True,
"mode": mode,
"drop_units": drop_count,
"dropped_tokens": dropped_len,
"previous_token_ids": previous_ids,
"previous_marker_token_ids": marker_ids,
"replacement_prompt_len": replacement_len,
}
if completed_terminator is not None:
duplex["stage0_window"]["completed_terminator_token_id"] = int(completed_terminator)
recorded_prefix_len = runtime_config.get("duplex_window_prefix_tokens")
prefix_len = preserve_len if recorded_prefix_len is None else int(recorded_prefix_len)
suffix_len = len(runtime_config.get("duplex_window_suffix_token_ids", ()) or ())
previous_len = int(getattr(session, "_minicpmo45_window_previous_len", 0) or 0)
new_preserve_len = prefix_len + previous_len + suffix_len if mode == "context" else preserve_len
session._minicpmo45_window_open_start = new_preserve_len + sum(int(unit["length"]) for unit in retained_units)
return True
# Prefix of the stop_reason carried by the FinishReason.ERROR output, so the
# serving side can map it to a stable error code.
STREAMING_CONTEXT_OVERFLOW_STOP_REASON = "context_length_exceeded"
def _streaming_update_overflows(self, session: Request, update: StreamingUpdate) -> bool:
def _streaming_update_overflows(
self,
session: Request,
update: StreamingUpdate,
*,
projected_len: int | None = None,
) -> bool:
"""Finish a streaming session whose next extension cannot fit the model.
Upstream ``_update_request_as_session`` appends the update to the
@@ -991,6 +1185,10 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
finish to the next ``schedule()`` would leave the client waiting. The
reason is emitted with the terminal output (see
:meth:`_emit_streaming_context_overflow_outputs`).
``projected_len`` overrides the extended-prompt projection for a caller
that replaces the prompt instead of growing it, such as the MiniCPM
Stage-0 window rebuild.
"""
max_model_len = getattr(self, "max_model_len", None)
if max_model_len is None:
@@ -1001,8 +1199,13 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
new_tokens = len(update.prompt_token_ids or ())
# The extended prompt is the current prompt plus the computed output
# tokens upstream keeps, then the update: num_computed_tokens covers
# both when the prompt was fully computed.
projected = max(int(session.num_prompt_tokens), int(session.num_computed_tokens)) + new_tokens
# both when the prompt was fully computed. A caller that already knows
# the post-update prompt length (a window rebuild) passes it instead.
projected = (
max(int(session.num_prompt_tokens), int(session.num_computed_tokens)) + new_tokens
if projected_len is None
else int(projected_len)
)
# Room for the tokens one step samples on top of the prompt (1 without
# speculative decoding). __new__-built test schedulers carry no
# num_sampled_tokens_per_step.
@@ -45,7 +45,10 @@ from vllm_omni.model_executor.models.minicpmo_4_5.duplex.data_plane import (
MiniCPMO45DataPlaneContext,
MiniCPMO45DataPlaneSession,
)
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.policy import MiniCPMO45DuplexPolicy
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.policy import (
MiniCPMO45DuplexPolicy,
MiniCPMO45DuplexWindowConfig,
)
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.session import (
MiniCPMO45ServingSessionState,
)
@@ -85,6 +88,11 @@ PRIVATE_RUNTIME_CONFIG_KEYS = frozenset(
"ref_audio_format",
"ref_audio_sample_rate_hz",
"initial_user_text",
"duplex_window_config",
"duplex_window_prefix_tokens",
"duplex_window_suffix_token_ids",
"duplex_window_previous_marker_token_ids",
"duplex_window_special_token_ids",
}
)
@@ -245,8 +253,8 @@ def build_duplex_data_plane_prompt(
token_budget = context_reserve + first_units * 12 - 1 + vision_tokens
if seq > 1 and duplex_payload_is_exact_chunks(payload):
token_budget += 1
if final and duplex_payload_is_exact_chunks(payload):
token_budget += 12
# Serving already pads the final residual audio. Stage0 does not
# append another silent unit, so final must not reserve extra slots.
extra_body = session_config.get("extra_body")
raw_token_id = runtime_config.get("duplex_scheduler_token_id")
try:
@@ -515,6 +523,16 @@ def _apply_first_append_context_tokens(
return
ref_tokens = MiniCPMO45DuplexPolicy.audio_token_count(ref_sample_count or 0)
runtime_config["duplex_first_append_context_tokens"] = len(prefix_ids) + ref_tokens + len(suffix_ids)
runtime_config["duplex_window_prefix_tokens"] = len(prefix_ids) + ref_tokens
runtime_config["duplex_window_suffix_token_ids"] = [int(token_id) for token_id in suffix_ids]
marker_ids = tokenizer.encode("\n\nprevious: ", add_special_tokens=False)
runtime_config["duplex_window_previous_marker_token_ids"] = [int(token_id) for token_id in marker_ids]
runtime_config["duplex_window_special_token_ids"] = sorted(
MiniCPMO45DuplexPolicy.native_special_token_ids(
MiniCPMO45DuplexPolicy.token_ids_from_tokenizer(tokenizer),
tokenizer_special_ids=list(getattr(tokenizer, "all_special_ids", ()) or ()),
)
)
def _model_vision_tile_pixels(model_config: ModelConfig | None) -> int | None:
@@ -722,6 +740,8 @@ class MiniCPMO45DuplexPlugin(DuplexModelPlugin):
code="unsupported_ref_audio_path",
)
runtime_config: dict[str, object] = {"instructions": config.instructions}
window_config = self._pop_window_config(extra_body) or MiniCPMO45DuplexWindowConfig()
runtime_config["duplex_window_config"] = window_config.as_dict()
# ``duplex_initial_user_text`` is the older extra_body spelling and
# still works; the session field is the framework-level one.
initial_user_text = extra_body.pop("duplex_initial_user_text", None)
@@ -788,6 +808,17 @@ class MiniCPMO45DuplexPlugin(DuplexModelPlugin):
current: Mapping[str, object],
) -> dict[str, object]:
runtime_config = deepcopy(dict(current))
extra_body = dict(config.extra_body)
requested_window = self._pop_window_config(extra_body)
if requested_window is not None:
reject_changed_runtime_value(
requested_window.as_dict(),
runtime_config.get("duplex_window_config"),
message="sliding-window configuration cannot be changed after the session is created",
code="sliding_window_update_unsupported",
error_cls=MiniCPMO45ClientRuntimeConfigError,
)
config.extra_body = extra_body
reject_changed_runtime_value(
config.instructions,
runtime_config.get("instructions"),
@@ -812,6 +843,23 @@ class MiniCPMO45DuplexPlugin(DuplexModelPlugin):
runtime_config["duplex_stage_sampling_params"] = stage_sampling
return runtime_config
@staticmethod
def _pop_window_config(extra_body: dict[str, object]) -> MiniCPMO45DuplexWindowConfig | None:
names = (
"sliding_window_mode",
"basic_window_high_tokens",
"basic_window_low_tokens",
"context_previous_max_tokens",
"context_max_units",
)
provided = {name: extra_body.pop(name) for name in names if name in extra_body}
if not provided:
return None
try:
return MiniCPMO45DuplexWindowConfig.from_mapping(provided)
except ValueError as exc:
raise MiniCPMO45ClientRuntimeConfigError(str(exc), code="invalid_sliding_window_config") from exc
def data_plane_context(
self,
*,
@@ -3,12 +3,63 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from transformers import PreTrainedTokenizerBase
@dataclass(frozen=True, slots=True)
class MiniCPMO45DuplexWindowConfig:
"""Stage-0 window settings matching the released checkpoint defaults."""
sliding_window_mode: str = "off"
basic_window_high_tokens: int = 8000
basic_window_low_tokens: int = 6000
context_previous_max_tokens: int = 500
context_max_units: int = 24
@classmethod
def from_mapping(cls, value: object) -> MiniCPMO45DuplexWindowConfig:
source = value if isinstance(value, dict) else {}
def integer(name: str, default: int) -> int:
raw = source.get(name, default)
if isinstance(raw, bool):
raise ValueError(f"{name} must be an integer")
try:
parsed = int(raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be an integer") from exc
if parsed <= 0:
raise ValueError(f"{name} must be greater than zero")
return parsed
mode = source.get("sliding_window_mode", "off")
if mode not in {"off", "basic", "context"}:
raise ValueError("sliding_window_mode must be one of: off, basic, context")
config = cls(
sliding_window_mode=str(mode),
basic_window_high_tokens=integer("basic_window_high_tokens", 8000),
basic_window_low_tokens=integer("basic_window_low_tokens", 6000),
context_previous_max_tokens=integer("context_previous_max_tokens", 500),
context_max_units=integer("context_max_units", 24),
)
if config.basic_window_low_tokens >= config.basic_window_high_tokens:
raise ValueError("basic_window_low_tokens must be less than basic_window_high_tokens")
return config
def as_dict(self) -> dict[str, int | str]:
return {
"sliding_window_mode": self.sliding_window_mode,
"basic_window_high_tokens": self.basic_window_high_tokens,
"basic_window_low_tokens": self.basic_window_low_tokens,
"context_previous_max_tokens": self.context_previous_max_tokens,
"context_max_units": self.context_max_units,
}
class MiniCPMO45DuplexPolicy:
"""MiniCPM-o 4.5 native duplex model policy.
@@ -27,6 +27,12 @@ _MINICPMO45_OPTIONAL_TOKEN_FIELDS = MiniCPMO45DuplexPolicy.OPTIONAL_TOKEN_FIELDS
_MINICPMO45_PROCESSOR_LOAD_LOCK = Lock()
@dataclass
class _MiniCPMO45WindowUnit:
embeds: list[Any] = field(default_factory=list)
token_ids: list[int] = field(default_factory=list)
@dataclass
class _MiniCPMO45Stage0SessionState:
session_id: str
@@ -36,6 +42,14 @@ class _MiniCPMO45Stage0SessionState:
audio_chunk_idx: int = 0
context_embeds: list[torch.Tensor] = field(default_factory=list)
context_token_ids: list[int] = field(default_factory=list)
context_prefix_embeds: list[Any] = field(default_factory=list)
context_prefix_token_ids: list[int] = field(default_factory=list)
context_suffix_embeds: list[Any] = field(default_factory=list)
context_suffix_token_ids: list[int] = field(default_factory=list)
window_units: list[_MiniCPMO45WindowUnit] = field(default_factory=list)
window_enabled: bool = False
pending_window_unit: _MiniCPMO45WindowUnit | None = None
pending_window_generated_tokens: list[int] = field(default_factory=list)
current_turn_ended: bool = True
prepared_append_identity: tuple[int | None, int] | None = None
prepared_inputs_embeds: torch.Tensor | None = None
@@ -142,6 +156,11 @@ class MiniCPMO45Stage0DuplexRuntime:
*,
runtime_config: dict[str, object] | None = None,
) -> None:
window_config = (runtime_config or {}).get("duplex_window_config")
state.window_enabled = isinstance(window_config, dict) and window_config.get("sliding_window_mode", "off") in {
"basic",
"context",
}
if not self._stage_runtime_ready():
return
self._require_special_token_ids()
@@ -157,17 +176,26 @@ class MiniCPMO45Stage0DuplexRuntime:
(runtime_config or {}).get("initial_user_text"),
)
for token_id in self._encode_text(prefix):
state.context_embeds.append(self._embed_token(token_id))
embed = self._embed_token(token_id)
state.context_embeds.append(embed)
state.context_token_ids.append(token_id)
state.context_prefix_embeds.append(embed)
state.context_prefix_token_ids.append(token_id)
if ref_audio is not None:
ref_audio_embeds = self._stage_ref_audio_embeddings(ref_audio, state=state)
if ref_audio_embeds is not None:
ref_audio_embeds = self._as_2d_tensor(ref_audio_embeds)
placeholder_ids = [self.unit_token_id] * int(ref_audio_embeds.shape[0])
state.context_embeds.append(ref_audio_embeds)
state.context_token_ids.extend([self.unit_token_id] * int(ref_audio_embeds.shape[0]))
state.context_token_ids.extend(placeholder_ids)
state.context_prefix_embeds.append(ref_audio_embeds)
state.context_prefix_token_ids.extend(placeholder_ids)
for token_id in self._encode_text(suffix):
state.context_embeds.append(self._embed_token(token_id))
embed = self._embed_token(token_id)
state.context_embeds.append(embed)
state.context_token_ids.append(token_id)
state.context_suffix_embeds.append(embed)
state.context_suffix_token_ids.append(token_id)
def _stage_prefill_embeddings_only(
self,
@@ -179,6 +207,7 @@ class MiniCPMO45Stage0DuplexRuntime:
seq: int | None = None,
is_speech: bool = False,
final: bool = False,
stage0_window: dict[str, object] | None = None,
) -> dict[str, object]:
"""Build scheduler-owned Stage0 input embeddings for one audio append.
@@ -199,6 +228,16 @@ class MiniCPMO45Stage0DuplexRuntime:
result["input_token_ids"] = list(state.prepared_input_token_ids)
return result
self._require_special_token_ids()
if isinstance(stage0_window, dict):
completed_ids = stage0_window.get("completed_token_ids")
if isinstance(completed_ids, list):
state.pending_window_generated_tokens = [int(token_id) for token_id in completed_ids]
completed_terminator = stage0_window.get("completed_terminator_token_id")
if isinstance(completed_terminator, int):
# Async sampling can overwrite the worker's pending token
# after the scheduler accepted a segment stop. Rebuild from
# the scheduler's canonical boundary, just like its content.
state.pending_terminator_token = completed_terminator
if audio_waveform is None or len(audio_waveform) == 0:
return self._stage_prefill_result(False, start_time, "empty audio")
state.audio_buffer = np.concatenate([state.audio_buffer, np.asarray(audio_waveform, dtype=np.float32)])
@@ -279,12 +318,25 @@ class MiniCPMO45Stage0DuplexRuntime:
# the closure; the model's listen/speak policy depends on
# seeing its own past decisions in context.
pending_terminator = state.pending_terminator_token
closure_token_ids: list[int] = []
if pending_terminator is not None and units_built == 0:
state.pending_terminator_token = None
embed_parts.append(self._embed_token(pending_terminator))
token_ids.append(int(pending_terminator))
closure_token_ids.append(int(pending_terminator))
embed_parts.append(self._embed_token(self.unit_end_token_id))
token_ids.append(self.unit_end_token_id)
closure_token_ids.append(self.unit_end_token_id)
if units_built == 0:
self._finalize_window_unit(state, closure_token_ids)
elif state.pending_window_unit is not None:
# Internal processor chunks belong to this same append.
state.pending_window_unit.embeds.extend(
self._embed_token(token_id) for token_id in closure_token_ids
)
state.pending_window_unit.token_ids.extend(closure_token_ids)
unit_embed_start = len(embed_parts)
unit_token_start = len(token_ids)
embed_parts.append(self._embed_token(self.unit_token_id))
token_ids.append(self.unit_token_id)
if frame_blocks:
@@ -311,6 +363,11 @@ class MiniCPMO45Stage0DuplexRuntime:
state.audio_buffer = state.audio_buffer[consumed_samples:]
state.audio_chunk_idx += 1
units_built += 1
if state.window_enabled:
if state.pending_window_unit is None:
state.pending_window_unit = _MiniCPMO45WindowUnit()
state.pending_window_unit.embeds.extend(embed_parts[unit_embed_start:])
state.pending_window_unit.token_ids.extend(token_ids[unit_token_start:])
chunk_size = self._streaming_chunk_size(processor)
# Match official streaming_prefill: per chunk feed ONLY <unit>+audio. The assistant
# turn is opened once at session init; re-emitting the turn-open prefix per chunk
@@ -320,6 +377,9 @@ class MiniCPMO45Stage0DuplexRuntime:
import torch
window_result = self._window_replacement_parts(state, stage0_window)
if window_result is not None:
embed_parts, token_ids = window_result
inputs_embeds = torch.cat([self._as_2d_tensor(embed) for embed in embed_parts], dim=0)
result = self._stage_prefill_result(True, start_time)
result.update(
@@ -332,6 +392,7 @@ class MiniCPMO45Stage0DuplexRuntime:
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
"stage0_window_replaced": window_result is not None,
}
)
if is_speech and (append_identity is None or state.pending_speech_append_identity != append_identity):
@@ -344,6 +405,85 @@ class MiniCPMO45Stage0DuplexRuntime:
state.prepared_result = {k: v for k, v in result.items() if k not in {"inputs_embeds", "input_token_ids"}}
return result
def _finalize_window_unit(
self,
state: _MiniCPMO45Stage0SessionState,
closure_token_ids: list[int],
) -> None:
pending = state.pending_window_unit
if not state.window_enabled or pending is None:
state.pending_window_unit = None
state.pending_window_generated_tokens.clear()
return
generated = list(state.pending_window_generated_tokens)
token_ids = [*pending.token_ids, *generated, *closure_token_ids]
embeds = list(pending.embeds)
embeds.extend(self._embed_token(token_id) for token_id in generated)
embeds.extend(self._embed_token(token_id) for token_id in closure_token_ids)
state.window_units.append(_MiniCPMO45WindowUnit(embeds=embeds, token_ids=token_ids))
state.pending_window_unit = None
state.pending_window_generated_tokens.clear()
def _window_replacement_parts(
self,
state: _MiniCPMO45Stage0SessionState,
stage0_window: dict[str, Any] | None,
) -> tuple[list[Any], list[int]] | None:
if not isinstance(stage0_window, dict) or stage0_window.get("replace") is not True:
return None
try:
drop_units = max(0, int(stage0_window.get("drop_units", 0)))
except (TypeError, ValueError):
return None
if drop_units > len(state.window_units):
raise RuntimeError(
"MiniCPM-o Stage-0 window history is shorter than the scheduler drop plan: "
f"drop={drop_units}, completed={len(state.window_units)}"
)
del state.window_units[:drop_units]
mode = stage0_window.get("mode")
embeds: list[Any] = []
token_ids: list[int] = []
previous_ids: list[int] = []
if mode == "context":
embeds.extend(state.context_prefix_embeds)
token_ids.extend(state.context_prefix_token_ids)
previous = stage0_window.get("previous_token_ids")
previous_ids = [int(token_id) for token_id in previous] if isinstance(previous, list) else []
if previous_ids:
# The plan carries the marker ids the scheduler sized the
# `previous` region from. Re-tokenizing here would be a second
# source of truth and could disagree with that length.
marker = stage0_window.get("previous_marker_token_ids")
marker_ids = [int(token_id) for token_id in marker] if isinstance(marker, list) else []
embeds.extend(self._embed_token(token_id) for token_id in [*marker_ids, *previous_ids])
token_ids.extend(marker_ids)
token_ids.extend(previous_ids)
embeds.extend(state.context_suffix_embeds)
token_ids.extend(state.context_suffix_token_ids)
else:
embeds.extend(state.context_embeds)
token_ids.extend(state.context_token_ids)
for unit in state.window_units:
embeds.extend(unit.embeds)
token_ids.extend(unit.token_ids)
if state.pending_window_unit is not None:
embeds.extend(state.pending_window_unit.embeds)
token_ids.extend(state.pending_window_unit.token_ids)
expected = stage0_window.get("replacement_prompt_len")
if isinstance(expected, int) and expected != len(token_ids):
unit_lengths = [len(unit.token_ids) for unit in state.window_units]
pending_len = len(state.pending_window_unit.token_ids) if state.pending_window_unit is not None else 0
raise RuntimeError(
"MiniCPM-o Stage-0 window rebuild length mismatch: "
f"worker={len(token_ids)}, scheduler={expected}, mode={mode}, "
f"prefix={len(state.context_prefix_token_ids)}, "
f"suffix={len(state.context_suffix_token_ids)}, previous={len(previous_ids)}, "
f"units={unit_lengths}, pending={pending_len}, drop={drop_units}"
)
return embeds, token_ids
@staticmethod
def _stage_prefill_result(success: bool, start_time: float, reason: str = "") -> dict[str, object]:
return {
@@ -371,8 +371,18 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
seq=seq,
is_speech=bool(payload.get("is_speech", False)),
final=bool(duplex.get("final")),
stage0_window=(duplex.get("stage0_window") if isinstance(duplex.get("stage0_window"), dict) else None),
)
update_result = dict(result)
if result.get("stage0_window_replaced") is True:
window = duplex.get("stage0_window", {})
logger.info(
"MiniCPM-o Stage-0 window replaced: mode=%s drop_units=%s tokens=%s seq=%s",
window.get("mode"),
window.get("drop_units"),
result.get("num_input_tokens"),
seq,
)
update_result.pop("inputs_embeds", None)
if result.get("success") is not True:
embeds = input_embeds if input_embeds is not None else self.get_input_embeddings(input_ids)
@@ -865,8 +865,25 @@ def llm2tts(
special_token_ids.get("chunk_eos_token_id"),
special_token_ids.get("chunk_tts_eos_token_id"),
}
tts_token_ids_slice = torch.tensor(full_token_ids[tts_bos_idx:end_idx], dtype=torch.long)
tts_hidden_slice = thinker_hidden_states[tts_bos_idx:end_idx].to(torch.float32).contiguous()
if is_native_duplex_handoff:
# Earlier unforwarded decisions may already be folded into
# the rebuilt prompt while still appearing in this delta.
# Align the explicit tts_bos path by the segment's end, just
# like the native speak/text paths below, rather than adding
# those decisions to the prompt a second time.
out_start = tts_bos_idx - prompt_token_ids_len
out_end = tts_eos_idx - prompt_token_ids_len if tts_eos_idx is not None else len(llm_output_ids)
hidden_base = int(thinker_hidden_states.shape[0]) - len(llm_output_ids)
if hidden_base >= 0 and out_end > out_start:
tts_token_ids_slice = torch.tensor(llm_output_ids[out_start:out_end], dtype=torch.long)
tts_hidden_slice = (
thinker_hidden_states[hidden_base + out_start : hidden_base + out_end]
.to(torch.float32)
.contiguous()
)
else:
tts_token_ids_slice = torch.tensor(full_token_ids[tts_bos_idx:end_idx], dtype=torch.long)
tts_hidden_slice = thinker_hidden_states[tts_bos_idx:end_idx].to(torch.float32).contiguous()
elif is_native_duplex_handoff:
# Official MiniCPM-o duplex does not prefill an assistant
# <|tts_bos|> boundary before generation. A segment delta can