Files
vllm-omni/vllm_omni/experimental/fullduplex/minicpmo45/runtime.py
T
05b794f29b [Full Duplex] Feat: Support Full-Duplex realtime runtime & add MiniCPM-o 4.5 demo (#3907)
Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: StormMelody <22371184@zju.edu.cn>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-07-23 16:45:18 +08:00

355 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from base64 import b64decode
from binascii import Error as BinasciiError
from typing import Any
from vllm.sampling_params import SamplingParams
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
DuplexAppendPlan,
DuplexInputMode,
DuplexOutputAction,
DuplexOutputDecision,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
_DUPLEX_CHUNK_SAMPLES = 16000
_DUPLEX_SAMPLES_PER_AUDIO_TOKEN = 1600
# <image> + 64 resampler embeddings + </image> per frame (max_slice_nums=1),
# matching MiniCPMO45DuplexPolicy.VISION_TOKENS_PER_FRAME.
_DUPLEX_VISION_TOKENS_PER_FRAME = 66
def _duplex_frame_count(payload: object) -> int:
if not isinstance(payload, dict):
return 0
frames = payload.get("video_frames")
if not isinstance(frames, list):
return 0
return sum(1 for frame in frames if isinstance(frame, str) and frame)
def _duplex_pcm_sample_count(payload: object) -> int | None:
if not isinstance(payload, dict):
return None
audio = payload.get("audio") or payload.get("data")
if payload.get("format") != "pcm_f32le" or not isinstance(audio, str):
return None
try:
raw = b64decode(audio, validate=True)
except (BinasciiError, ValueError):
return None
return len(raw) // 4
def duplex_payload_is_exact_chunks(payload: object) -> bool:
sample_count = _duplex_pcm_sample_count(payload)
return bool(sample_count) and sample_count % _DUPLEX_CHUNK_SAMPLES == 0
def duplex_first_append_unit_count(payload: object) -> int | None:
sample_count = _duplex_pcm_sample_count(payload)
if not sample_count or sample_count % _DUPLEX_CHUNK_SAMPLES != 0:
return None
return max(1, sample_count // _DUPLEX_CHUNK_SAMPLES - 1)
def duplex_scheduler_token_budget(payload: object, *, default: int = 64) -> int:
vision_tokens = _duplex_frame_count(payload) * _DUPLEX_VISION_TOKENS_PER_FRAME
sample_count = _duplex_pcm_sample_count(payload)
if sample_count is None:
return max(1, int(default)) + vision_tokens
sample_count = max(1, sample_count)
if sample_count % _DUPLEX_CHUNK_SAMPLES == 0:
units = sample_count // _DUPLEX_CHUNK_SAMPLES
return units * (2 + _DUPLEX_CHUNK_SAMPLES // _DUPLEX_SAMPLES_PER_AUDIO_TOKEN) + vision_tokens
return max(16, min(768, sample_count // _DUPLEX_SAMPLES_PER_AUDIO_TOKEN + 8)) + vision_tokens
def duplex_first_append_context_reserve(runtime_config: object) -> int:
if not isinstance(runtime_config, dict):
return 48
exact = runtime_config.get("duplex_first_append_context_tokens")
if isinstance(exact, int) and exact >= 0:
return exact
reserve = 48
ref = runtime_config.get("ref_audio_data")
if isinstance(ref, str) and ref:
try:
raw = b64decode(ref, validate=True)
except (BinasciiError, ValueError):
raw = b""
if raw:
reserve += max(0, (len(raw) // 4) // _DUPLEX_SAMPLES_PER_AUDIO_TOKEN + 8)
return reserve
def _duplex_force_listen_count(extra_body: object) -> int:
raw = extra_body.get("force_listen_count") if isinstance(extra_body, dict) else None
try:
return 0 if raw is None else max(0, int(raw))
except (TypeError, ValueError):
return 0
def build_duplex_data_plane_prompt(
*,
request_id: str,
fence: DuplexFence,
session_config: dict[str, Any],
runtime_config: dict[str, Any],
seq: int,
turn_seq: int,
mode: DuplexInputMode,
payload: object,
final: bool,
) -> dict[str, Any]:
token_budget = duplex_scheduler_token_budget(payload)
if seq <= 1:
context_reserve = duplex_first_append_context_reserve(runtime_config)
token_budget += context_reserve
first_units = duplex_first_append_unit_count(payload)
if first_units is not None:
token_budget = (
context_reserve + first_units * 12 - 1 + _duplex_frame_count(payload) * _DUPLEX_VISION_TOKENS_PER_FRAME
)
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
extra_body = session_config.get("extra_body")
raw_token_id = runtime_config.get("duplex_scheduler_token_id")
try:
token_id = max(0, int(raw_token_id))
except (TypeError, ValueError):
token_id = 0
force_listen_count = _duplex_force_listen_count(extra_body)
if (
force_listen_count > 0
and turn_seq <= force_listen_count
and isinstance(payload, dict)
and payload.get("force_listen") is not True
):
payload = {**payload, "force_listen": True}
return {
"prompt_token_ids": [token_id] * token_budget,
"model_intermediate_buffer": {
"request_id": request_id,
"global_request_id": [fence.session_id],
"duplex": {
"fence": fence,
"session_id": fence.session_id,
"incarnation": fence.incarnation,
"epoch": fence.epoch,
"seq": seq,
"turn_id": fence.turn_id,
"response_seq": fence.response_seq,
"turn_seq": turn_seq,
"mode": mode.value,
"payload": payload,
"final": final,
"data_plane": True,
"session_config": dict(session_config),
"runtime_config": dict(runtime_config),
"scheduler_token_budget": token_budget,
"scheduler_token_id": token_id,
},
},
}
def _coerce_int(value: object) -> int | None:
if hasattr(value, "detach"):
try:
value = value.detach().cpu().reshape(-1)
if value.numel() == 0:
return None
value = value[0].item()
except Exception:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_int_list(value: object) -> list[int]:
if value is None:
return []
if hasattr(value, "detach"):
try:
value = value.detach().cpu().reshape(-1).tolist()
except Exception:
return []
if not isinstance(value, (list, tuple)):
return []
return [token_id for item in value if (token_id := _coerce_int(item)) is not None]
def _first_completion(output: object) -> object | None:
outputs = getattr(output, "outputs", None)
return outputs[0] if isinstance(outputs, list) and outputs else None
def _multimodal_output(output: object, completion: object | None) -> dict[str, Any]:
metadata = getattr(output, "multimodal_output", None)
if isinstance(metadata, dict):
return metadata
metadata = getattr(completion, "multimodal_output", None) if completion is not None else None
return metadata if isinstance(metadata, dict) else {}
def _special_token_ids(metadata: dict[str, Any]) -> dict[str, int]:
sources: list[object] = [metadata.get("special_token_ids"), metadata.get("meta")]
sources.append(
{
key.removeprefix("meta."): value
for key, value in metadata.items()
if isinstance(key, str) and key.startswith("meta.")
}
)
token_ids: dict[str, int] = {}
for source in sources:
if not isinstance(source, dict):
continue
for key, value in source.items():
token_id = _coerce_int(value)
if isinstance(key, str) and token_id is not None and token_id >= 0:
token_ids[key] = token_id
return token_ids
def _completion_token_ids(completion: object | None) -> list[int]:
if completion is None:
return []
for attribute in ("token_ids", "cumulative_token_ids"):
token_ids = _coerce_int_list(getattr(completion, attribute, None))
if token_ids:
return token_ids
return []
def _stage_config_value(runtime_config: dict[str, Any], key: str, stage_id: int) -> object | None:
raw = runtime_config.get(key)
if isinstance(raw, dict):
value = raw.get(stage_id)
return raw.get(str(stage_id)) if value is None else value
if isinstance(raw, (list, tuple)) and stage_id < len(raw):
return raw[stage_id]
return None
class MiniCPMO45DuplexRuntimeExtension:
def configure_sampling_params(
self,
*,
runtime_config: dict[str, Any],
defaults: tuple[object, ...],
) -> tuple[object, ...]:
configured: list[object] = []
for stage_id, default in enumerate(defaults):
max_tokens = _coerce_int(_stage_config_value(runtime_config, "duplex_stage_max_tokens", stage_id))
raw_overrides = _stage_config_value(runtime_config, "duplex_stage_sampling_params", stage_id)
overrides = dict(raw_overrides) if isinstance(raw_overrides, dict) else {}
if not isinstance(default, SamplingParams) or (not overrides and (max_tokens is None or max_tokens <= 0)):
configured.append(default)
continue
params = default.clone()
if max_tokens is not None and max_tokens > 0:
params.max_tokens = max_tokens
for name, value in overrides.items():
if not hasattr(params, name):
continue
setattr(params, name, value)
if name == "stop_token_ids":
all_stop_token_ids = getattr(params, "_all_stop_token_ids", None)
if isinstance(all_stop_token_ids, set):
all_stop_token_ids.update(int(token_id) for token_id in value)
configured.append(params)
return tuple(configured)
def plan_append(
self,
*,
request_id: str,
fence: DuplexFence,
session_config: dict[str, Any],
runtime_config: dict[str, Any],
seq: int,
turn_seq: int,
mode: DuplexInputMode,
payload: object,
final: bool,
sampling_params: object,
) -> DuplexAppendPlan:
del sampling_params
return DuplexAppendPlan(
prompt=build_duplex_data_plane_prompt(
request_id=request_id,
fence=fence,
session_config=session_config,
runtime_config=runtime_config,
seq=seq,
turn_seq=turn_seq,
mode=mode,
payload=payload,
final=final,
)
)
def decide_output(
self,
*,
stage_id: int,
final_stage_id: int,
segment_finished: bool,
segment_token_ids: tuple[int, ...],
segment_output_metadata: dict[str, Any],
output: object,
) -> DuplexOutputDecision | None:
if stage_id >= final_stage_id or not segment_finished:
return None
completion = _first_completion(output)
output_metadata = _multimodal_output(output, completion)
special_token_ids = _special_token_ids(segment_output_metadata)
special_token_ids.update(_special_token_ids(output_metadata))
listen_id = special_token_ids.get("listen_token_id")
if listen_id is None:
return None
stop_reason = getattr(completion, "stop_reason", None) if completion is not None else None
token_ids = _completion_token_ids(completion) or list(segment_token_ids)
if _coerce_int(stop_reason) != listen_id and (not token_ids or token_ids[-1] != listen_id):
return None
metadata = dict(output_metadata)
for key, value in special_token_ids.items():
metadata.setdefault(f"meta.{key}", value)
metadata.update(
{
"duplex_direct_response": True,
"duplex_native_decision": "listen",
"model_listen": True,
"listen_source": "model_listen",
}
)
return DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata=metadata,
)
__all__ = [
"MiniCPMO45DuplexRuntimeExtension",
"build_duplex_data_plane_prompt",
"duplex_first_append_context_reserve",
"duplex_first_append_unit_count",
"duplex_payload_is_exact_chunks",
"duplex_scheduler_token_budget",
]