[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>
This commit is contained in:
142 files changed
+48389
-1013
No files matched your search
@@ -1,159 +1,139 @@
|
||||
# MiniCPM-o 4.5: Online serving
|
||||
|
||||
OpenAI-compatible `/v1/chat/completions` serving for **MiniCPM-o 4.5**, plus a
|
||||
Gradio UI and curl / Python clients.
|
||||
This directory contains MiniCPM-o 4.5 online serving demos for vLLM-Omni.
|
||||
Inputs can include text, image, audio, or video; outputs are text and optional
|
||||
24 kHz speech.
|
||||
|
||||
Inputs: text / image / audio / video. Outputs: text and optional **24 kHz** speech.
|
||||
For the experimental native duplex runtime architecture, lifecycle invariants,
|
||||
capability boundary, and validation scope, see
|
||||
[`vllm_omni/experimental/fullduplex/DESIGN.md`](../../../vllm_omni/experimental/fullduplex/DESIGN.md).
|
||||
|
||||
## Installation
|
||||
|
||||
Please refer to [README.md](../../../README.md). Install the talker extra:
|
||||
Install vLLM-Omni with the MiniCPM-o talker dependencies:
|
||||
|
||||
```bash
|
||||
pip install 'vllm-omni[minicpmo]'
|
||||
|
||||
# From a source checkout:
|
||||
pip install -e '.[minicpmo]'
|
||||
```
|
||||
|
||||
## Launch the server
|
||||
The `minicpmo` extra installs `stepaudio2-minicpmo` and its audio dependencies,
|
||||
including `librosa`.
|
||||
|
||||
The deploy config auto-loads via `--omni`; the default
|
||||
`vllm_omni/deploy/minicpmo_4_5.yaml` targets a single-GPU layout (thinker
|
||||
and talker + t2w co-located on GPU 0). For other hardware layouts pick
|
||||
one of the deploy variants below.
|
||||
## Start the backend server
|
||||
|
||||
| deploy config | GPUs | Notes |
|
||||
|---|---|---|
|
||||
| `minicpmo_4_5.yaml` (default) | 1 | Thinker and talker+t2w co-located on GPU0. |
|
||||
| `minicpmo_4_5_2gpu.yaml` | 2 | Thinker on GPU0, talker+t2w on GPU1. |
|
||||
| `minicpmo_4_5_3gpu.yaml` | 3 | Thinker 2-way TP on GPU0/1, talker+t2w share GPU2. |
|
||||
| `minicpmo_4_5_8x4090.yaml` | 8 | Full 8x4090 layout. |
|
||||
Pick a deploy config that matches your GPU layout:
|
||||
|
||||
Default (single-GPU):
|
||||
| config | GPUs | TP | Notes |
|
||||
|---|---|---|---|
|
||||
| `minicpmo_4_5.yaml` | 1 | 1 | Thinker and talker+t2w co-located on GPU0. |
|
||||
| `minicpmo_4_5_2gpu.yaml` | 2 | 1 | Thinker on GPU0, talker+t2w on GPU1. |
|
||||
| `minicpmo_4_5_3gpu.yaml` | 3 | 2 | Thinker 2-way TP on GPU0/1, talker+t2w share GPU2. |
|
||||
| `minicpmo_4_5_8x4090.yaml` | 8 | 4 | Thinker 4-way TP on GPU0-3, talker+t2w on GPU4. |
|
||||
| `minicpmo_4_5_3gpu_stage1_replicas.yaml` | 3 | 1 | Experimental validation profile: Thinker on GPU0, two talker+Token2wav replicas on GPU1/2. |
|
||||
| `minicpmo_4_5_4gpu_stage1_replicas.yaml` | 4 | 1 | Experimental validation profile: Thinker on GPU0, three talker+Token2wav replicas on GPU1/2/3. |
|
||||
| `minicpmo_4_5_8x4090_stage1_replicas.yaml` | 8 | 4 | Experimental validation profile: Thinker 4-way TP on GPU0-3, four talker+Token2wav replicas on GPU4-7. |
|
||||
| `minicpmo_4_5_duplex.yaml` | 2 | 1 | Experimental native duplex profile. |
|
||||
|
||||
```bash
|
||||
vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
vllm-omni serve openbmb/MiniCPM-o-4_5 \
|
||||
--omni \
|
||||
--deploy-config vllm_omni/deploy/minicpmo_4_5.yaml \
|
||||
--trust-remote-code \
|
||||
--host 0.0.0.0 --port 8099
|
||||
```
|
||||
|
||||
Other layouts:
|
||||
For local ModelScope checkpoints, replace `openbmb/MiniCPM-o-4_5` with the
|
||||
checkpoint path. To start the experimental native duplex backend, use
|
||||
`vllm_omni/deploy/minicpmo_4_5_duplex.yaml`.
|
||||
|
||||
```bash
|
||||
vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
--deploy-config vllm_omni/deploy/minicpmo_4_5_3gpu.yaml \
|
||||
--trust-remote-code \
|
||||
--host 0.0.0.0 --port 8099
|
||||
```
|
||||
The `*_stage1_replicas.yaml` files exercise composite Stage1 replica routing
|
||||
and failure recovery. They are validation profiles, not recommended production
|
||||
entrypoints.
|
||||
|
||||
### Stage-based CLI (optional)
|
||||
## Send chat requests
|
||||
|
||||
Stage 0 (thinker + API) and stage 1 (talker) can run in separate processes:
|
||||
|
||||
```bash
|
||||
# Stage 0
|
||||
CUDA_VISIBLE_DEVICES=0 vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
--trust-remote-code --port 8099 --stage-id 0 \
|
||||
--omni-master-address 127.0.0.1 --omni-master-port 26000
|
||||
|
||||
# Stage 1 (headless)
|
||||
CUDA_VISIBLE_DEVICES=1 vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
--trust-remote-code --stage-id 1 --headless \
|
||||
--omni-master-address 127.0.0.1 --omni-master-port 26000
|
||||
```
|
||||
|
||||
### Per-stage overrides
|
||||
|
||||
```bash
|
||||
vllm serve openbmb/MiniCPM-o-4_5 --omni --trust-remote-code --port 8099 \
|
||||
--stage-overrides '{"0": {"gpu_memory_utilization": 0.65}}'
|
||||
```
|
||||
|
||||
## Send multimodal requests
|
||||
|
||||
```bash
|
||||
cd examples/online_serving/minicpmo
|
||||
```
|
||||
|
||||
### curl
|
||||
From this directory, run the MiniCPM-specific curl or Python clients:
|
||||
|
||||
```bash
|
||||
bash run_curl_multimodal_generation.sh text
|
||||
bash run_curl_multimodal_generation.sh use_image
|
||||
bash run_curl_multimodal_generation.sh use_audio '["text"]' # text-only
|
||||
```
|
||||
bash run_curl_multimodal_generation.sh use_audio '["text"]'
|
||||
|
||||
Text + speech smoke test (TTS needs top-level `chat_template_kwargs`):
|
||||
|
||||
```bash
|
||||
curl http://localhost:8099/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "openbmb/MiniCPM-o-4_5",
|
||||
"messages": [{"role": "user", "content": "Say hello, then introduce vLLM in one sentence."}],
|
||||
"modalities": ["text", "audio"],
|
||||
"chat_template_kwargs": {"use_tts_template": true}
|
||||
}'
|
||||
```
|
||||
|
||||
### Python client
|
||||
|
||||
```bash
|
||||
python openai_chat_completion_client_for_multimodal_generation.py \
|
||||
--query-type use_image \
|
||||
--port 8099 \
|
||||
--host localhost
|
||||
|
||||
# Text-only (faster; no <|tts_bos|>)
|
||||
python openai_chat_completion_client_for_multimodal_generation.py \
|
||||
--query-type text \
|
||||
--modalities text \
|
||||
--prompt "Briefly introduce yourself."
|
||||
```
|
||||
|
||||
Shared helpers also work if you pass MiniCPM defaults yourself:
|
||||
|
||||
```bash
|
||||
python ../openai_chat_completion_client_for_multimodal_generation.py \
|
||||
--model openbmb/MiniCPM-o-4_5 \
|
||||
--query-type text \
|
||||
--host localhost \
|
||||
--port 8099
|
||||
```
|
||||
|
||||
(Note: the shared client does **not** set `use_tts_template`; prefer the
|
||||
MiniCPM-specific client above for speech.)
|
||||
Speech output requires `chat_template_kwargs.use_tts_template=true`. Put that
|
||||
field at the request root for curl; the OpenAI Python SDK can merge it from
|
||||
`extra_body`.
|
||||
|
||||
### Gradio demo
|
||||
## Launch the Gradio demo
|
||||
|
||||
```bash
|
||||
bash run_gradio_demo.sh
|
||||
bash examples/online_serving/minicpmo/run_gradio_demo.sh
|
||||
|
||||
# Or:
|
||||
python gradio_demo.py \
|
||||
# Or run the Python entry point directly:
|
||||
python examples/online_serving/minicpmo/gradio_demo.py \
|
||||
--minicpmo45-api-base http://localhost:8099/v1 \
|
||||
--minicpmo45-model openbmb/MiniCPM-o-4_5 \
|
||||
--port 7862
|
||||
```
|
||||
|
||||
Open `http://<host>:7862`. Uncheck **"Generate speech output (TTS)"** for
|
||||
text-only responses.
|
||||
Open `http://<host>:7862` in a browser.
|
||||
|
||||
## Modality control
|
||||
## Run the Realtime duplex CLI demo
|
||||
|
||||
| Modalities | Output |
|
||||
|---|---|
|
||||
| `["text"]` | Text only (no TTS bos) |
|
||||
| `["text", "audio"]` / unset | Text + 24 kHz speech |
|
||||
After the duplex backend is running, stream one WAV through the Realtime
|
||||
WebSocket endpoint:
|
||||
|
||||
Speech requires `chat_template_kwargs.use_tts_template=true` so the chat
|
||||
template appends `<|tts_bos|>`. For **curl**, put that field at the request
|
||||
root; nested `extra_body` is ignored. The OpenAI Python SDK may use
|
||||
`extra_body` because it merges those fields into the root.
|
||||
```bash
|
||||
python examples/online_serving/minicpmo/realtime_duplex_demo.py \
|
||||
--url ws://localhost:8099/v1/realtime?duplex=1 \
|
||||
--model openbmb/MiniCPM-o-4_5 \
|
||||
--input-wav /path/to/input_16k_mono_pcm16.wav \
|
||||
--ref-audio /path/to/MiniCPM-o-Demo/assets/ref_audio/ref_minicpm_signature.wav \
|
||||
--output-dir /tmp/minicpmo_realtime_duplex_demo
|
||||
```
|
||||
|
||||
## Notes
|
||||
## Open the experimental browser client
|
||||
|
||||
- Stage 1 is capped at `max_num_seqs: 1` in the deploy YAML (talker shares
|
||||
request-0 audio metadata).
|
||||
- Output audio is base64 WAV in `message.audio.data` (24 kHz mono).
|
||||
- Offline counterpart:
|
||||
[`examples/offline_inference/minicpmo/`](../../offline_inference/minicpmo/)
|
||||
- Recipe:
|
||||
[`recipes/OpenBMB/MiniCPM-o-4_5.md`](../../../recipes/OpenBMB/MiniCPM-o-4_5.md)
|
||||
The browser UI serves the page and proxies the same-origin Realtime WebSocket to
|
||||
the backend:
|
||||
|
||||
```bash
|
||||
python -m examples.online_serving.minicpmo.realtime_web \
|
||||
--port 7862 \
|
||||
--ws-backend ws://127.0.0.1:8099 \
|
||||
--ref-audio /path/to/MiniCPM-o-Demo/assets/ref_audio/ref_minicpm_signature.wav
|
||||
```
|
||||
|
||||
Open `http://<host>:7862/`. When using a reverse proxy, open the URL mapped to
|
||||
port `7862`; the browser derives its WebSocket endpoint relative to that URL.
|
||||
|
||||
If the page proxy serves HTTP but does not forward WebSocket upgrades, point the
|
||||
browser at a separately exposed Realtime endpoint:
|
||||
|
||||
```bash
|
||||
python -m examples.online_serving.minicpmo.realtime_web \
|
||||
--port 7862 \
|
||||
--ws-backend ws://127.0.0.1:8099 \
|
||||
--public-realtime-url wss://public.example/v1/realtime
|
||||
```
|
||||
|
||||
## Validate soft-interrupt behavior
|
||||
|
||||
The soft-interrupt E2E driver defaults to `--validation-mode model-policy`,
|
||||
which checks lifecycle and streaming invariants for arbitrary input audio. The
|
||||
stronger `response-required` mode is diagnostic: it requires a purpose-built
|
||||
two-response WAV, its `--input-sha256`, and an
|
||||
`--expect-second-response-substring` value.
|
||||
|
||||
## Related examples
|
||||
|
||||
- [Offline MiniCPM-o inference](../../offline_inference/minicpmo/)
|
||||
- [MiniCPM-o 4.5 recipe](../../../recipes/OpenBMB/MiniCPM-o-4_5.md)
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Minimal single-input MiniCPM-o 4.5 Realtime duplex demo.
|
||||
|
||||
Run this after starting the duplex server. Strict lifecycle, overlap, and
|
||||
multi-session validation lives under ``tests/e2e/online_serving``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
PCM16_BYTES_PER_SAMPLE,
|
||||
PCM16_SAMPLE_RATE,
|
||||
RealtimeDuplexClient,
|
||||
RealtimeEventCollector,
|
||||
build_realtime_url,
|
||||
read_pcm16_wav,
|
||||
wait_for,
|
||||
write_pcm16_wav,
|
||||
)
|
||||
|
||||
|
||||
class _StreamingOutputWriter:
|
||||
"""Persist and report output deltas as the client receives them."""
|
||||
|
||||
def __init__(self, output_dir: Path) -> None:
|
||||
self.output_dir = output_dir
|
||||
self.audio_chunk_dir = output_dir / "audio_chunks"
|
||||
self.audio_chunk_paths: list[Path] = []
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.audio_chunk_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.output_dir / "output.pcm").write_bytes(b"")
|
||||
|
||||
def handle(self, event: dict[str, object]) -> None:
|
||||
event_type = event.get("type")
|
||||
if event_type in {
|
||||
"response.audio_transcript.delta",
|
||||
"response.output_text.delta",
|
||||
}:
|
||||
delta = event.get("delta")
|
||||
if isinstance(delta, str) and delta:
|
||||
print(delta, end="", file=sys.stderr, flush=True)
|
||||
return
|
||||
if event_type != "response.audio.delta":
|
||||
return
|
||||
|
||||
delta = event.get("delta") or event.get("audio")
|
||||
if not isinstance(delta, str) or not delta:
|
||||
return
|
||||
try:
|
||||
pcm16 = base64.b64decode(delta)
|
||||
except ValueError:
|
||||
return
|
||||
if not pcm16:
|
||||
return
|
||||
|
||||
chunk_index = len(self.audio_chunk_paths) + 1
|
||||
chunk_path = self.audio_chunk_dir / f"chunk_{chunk_index:04d}.wav"
|
||||
sample_rate_hz = event.get("sample_rate_hz")
|
||||
if not isinstance(sample_rate_hz, int) or sample_rate_hz <= 0:
|
||||
sample_rate_hz = 24_000
|
||||
with (self.output_dir / "output.pcm").open("ab") as output_pcm:
|
||||
output_pcm.write(pcm16)
|
||||
write_pcm16_wav(chunk_path, pcm16, sample_rate_hz=sample_rate_hz)
|
||||
self.audio_chunk_paths.append(chunk_path)
|
||||
print(
|
||||
f"\n[audio chunk {chunk_index}: {len(pcm16)} bytes -> {chunk_path}]",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
class _StreamingEventCollector(RealtimeEventCollector):
|
||||
def __init__(self, writer: _StreamingOutputWriter) -> None:
|
||||
super().__init__()
|
||||
self._writer = writer
|
||||
|
||||
def add(self, event: dict[str, object], *, received_at_s: float | None = None) -> None:
|
||||
super().add(event, received_at_s=received_at_s)
|
||||
self._writer.handle(self.events[-1])
|
||||
|
||||
|
||||
def _input_committed_index(
|
||||
events: list[dict[str, object]],
|
||||
after_index: int,
|
||||
) -> int | None:
|
||||
for index, event in enumerate(events[max(after_index, 0) :], start=max(after_index, 0)):
|
||||
if event.get("type") == "input_audio_buffer.committed":
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _post_commit_model_decision(
|
||||
events: list[dict[str, object]],
|
||||
committed_index: int | None,
|
||||
) -> str | None:
|
||||
if committed_index is None:
|
||||
return None
|
||||
for event in events[committed_index + 1 :]:
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.listen":
|
||||
return "listen"
|
||||
if event_type == "response.done":
|
||||
response = event.get("response")
|
||||
if not isinstance(response, dict) or response.get("status") != "cancelled":
|
||||
return "speak"
|
||||
return None
|
||||
|
||||
|
||||
def _latest_model_decision(
|
||||
events: list[dict[str, object]],
|
||||
after_index: int,
|
||||
) -> str | None:
|
||||
decision: str | None = None
|
||||
for event in events[max(after_index, 0) :]:
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.listen":
|
||||
decision = "listen"
|
||||
elif event_type == "response.done":
|
||||
response = event.get("response")
|
||||
if not isinstance(response, dict) or response.get("status") != "cancelled":
|
||||
decision = "speak"
|
||||
return decision
|
||||
|
||||
|
||||
def _chunk_period_ms(events: list[dict[str, object]]) -> int:
|
||||
for event in reversed(events):
|
||||
session = event.get("session")
|
||||
if not isinstance(session, dict):
|
||||
continue
|
||||
capabilities = session.get("capabilities")
|
||||
if not isinstance(capabilities, dict):
|
||||
continue
|
||||
chunk_period_ms = capabilities.get("chunk_period_ms")
|
||||
if isinstance(chunk_period_ms, int) and chunk_period_ms > 0:
|
||||
return chunk_period_ms
|
||||
return 1000
|
||||
|
||||
|
||||
def _has_residual_model_unit(pcm16: bytes, *, chunk_period_ms: int) -> bool:
|
||||
unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_period_ms // 1000
|
||||
return bool(unit_bytes > 0 and len(pcm16) % unit_bytes)
|
||||
|
||||
|
||||
def _response_in_progress(events: list[dict[str, object]]) -> bool:
|
||||
return sum(event.get("type") == "response.created" for event in events) > sum(
|
||||
event.get("type") == "response.done" for event in events
|
||||
)
|
||||
|
||||
|
||||
def _event_count_after(
|
||||
events: list[dict[str, object]],
|
||||
event_type: str,
|
||||
index: int | None,
|
||||
) -> int:
|
||||
if index is None:
|
||||
return 0
|
||||
return sum(event.get("type") == event_type for event in events[index + 1 :])
|
||||
|
||||
|
||||
def _ref_audio_data_url(path: str | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
ref_path = Path(path).expanduser()
|
||||
return "data:audio/wav;base64," + base64.b64encode(ref_path.read_bytes()).decode("ascii")
|
||||
|
||||
|
||||
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
input_pcm16 = read_pcm16_wav(Path(args.input_wav))
|
||||
if not input_pcm16:
|
||||
raise ValueError("input WAV has no audio")
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
stream_writer = _StreamingOutputWriter(output_dir)
|
||||
|
||||
url = build_realtime_url(
|
||||
args.url,
|
||||
args.model,
|
||||
autostart=False if args.ref_audio else None,
|
||||
session_id=args.session_id,
|
||||
)
|
||||
client = RealtimeDuplexClient(url)
|
||||
client.events = _StreamingEventCollector(stream_writer)
|
||||
async with client:
|
||||
await client.configure(
|
||||
args.model,
|
||||
ref_audio=_ref_audio_data_url(args.ref_audio),
|
||||
session_id=args.session_id,
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
stream_event_cursor = len(client.events.events)
|
||||
await client.stream_pcm16(
|
||||
input_pcm16,
|
||||
chunk_ms=args.chunk_ms,
|
||||
realtime=not args.no_realtime_pacing,
|
||||
)
|
||||
commit_event_cursor = len(client.events.events)
|
||||
stream_decision = _latest_model_decision(client.events.events, stream_event_cursor)
|
||||
input_has_residual_model_unit = _has_residual_model_unit(
|
||||
input_pcm16,
|
||||
chunk_period_ms=_chunk_period_ms(client.events.events),
|
||||
)
|
||||
wait_for_post_commit_decision = False
|
||||
commit_sent_at_s = time.monotonic()
|
||||
await client.commit()
|
||||
wait_error: str | None = None
|
||||
committed_index: int | None = None
|
||||
post_commit_decision: str | None = None
|
||||
try:
|
||||
await wait_for(
|
||||
lambda: _input_committed_index(client.events.events, commit_event_cursor) is not None,
|
||||
timeout_s=args.timeout_s,
|
||||
label="input_audio_buffer.committed",
|
||||
)
|
||||
committed_index = _input_committed_index(client.events.events, commit_event_cursor)
|
||||
stream_decision = _latest_model_decision(client.events.events[: committed_index + 1], stream_event_cursor)
|
||||
wait_for_post_commit_decision = input_has_residual_model_unit or _response_in_progress(
|
||||
client.events.events[: committed_index + 1]
|
||||
)
|
||||
if wait_for_post_commit_decision:
|
||||
await wait_for(
|
||||
lambda: _post_commit_model_decision(client.events.events, committed_index) is not None,
|
||||
timeout_s=args.timeout_s,
|
||||
label="post-commit model decision or response drain",
|
||||
)
|
||||
post_commit_decision = _post_commit_model_decision(client.events.events, committed_index)
|
||||
except TimeoutError as exc:
|
||||
wait_error = str(exc)
|
||||
await client.acknowledge_playback()
|
||||
close_error: str | None = None
|
||||
try:
|
||||
await client.close_session(timeout_s=args.timeout_s)
|
||||
except TimeoutError as exc:
|
||||
close_error = str(exc)
|
||||
|
||||
audio = client.events.audio_bytes()
|
||||
first_text_at_s = client.events.first_received_at(
|
||||
"response.audio_transcript.delta",
|
||||
"response.output_text.delta",
|
||||
after_s=commit_sent_at_s,
|
||||
)
|
||||
first_audio_at_s = client.events.first_received_at(
|
||||
"response.audio.delta",
|
||||
after_s=commit_sent_at_s,
|
||||
)
|
||||
response_created_at_s = client.events.first_received_at(
|
||||
"response.created",
|
||||
after_s=commit_sent_at_s,
|
||||
)
|
||||
response_done_at_s = client.events.first_received_at(
|
||||
"response.done",
|
||||
after_s=commit_sent_at_s,
|
||||
)
|
||||
audio_duration_s = len(audio) / (client.events.output_sample_rate_hz * 2)
|
||||
response_generation_s = (
|
||||
response_done_at_s - response_created_at_s
|
||||
if response_done_at_s is not None and response_created_at_s is not None
|
||||
else None
|
||||
)
|
||||
transcript_deltas = [
|
||||
str(event.get("delta", ""))
|
||||
for event in client.events.events
|
||||
if event.get("type")
|
||||
in {
|
||||
"response.audio_transcript.delta",
|
||||
"response.output_text.delta",
|
||||
}
|
||||
]
|
||||
response_id = client.events.response_ids[0] if client.events.response_ids else None
|
||||
timing = client.events.timing_summary(
|
||||
after_s=commit_sent_at_s,
|
||||
input_committed_at_s=commit_sent_at_s,
|
||||
response_id=response_id,
|
||||
)
|
||||
errors = client.events.errors()
|
||||
if wait_error:
|
||||
errors.append({"type": "client.timeout", "message": wait_error})
|
||||
if close_error:
|
||||
errors.append({"type": "client.timeout", "message": close_error})
|
||||
model_decision = post_commit_decision or stream_decision
|
||||
(output_dir / "events.jsonl").write_text(
|
||||
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(output_dir / "output.pcm").write_bytes(audio)
|
||||
if audio:
|
||||
write_pcm16_wav(
|
||||
output_dir / "output.wav",
|
||||
audio,
|
||||
sample_rate_hz=client.events.output_sample_rate_hz,
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": (
|
||||
client.events.count("session.created") > 0
|
||||
and client.events.count("session.closed") > 0
|
||||
and not errors
|
||||
and model_decision is not None
|
||||
and (bool(audio) or not args.require_audio)
|
||||
),
|
||||
"model_decision": model_decision,
|
||||
"post_commit": {
|
||||
"input_committed_event_index": committed_index,
|
||||
"decision": post_commit_decision,
|
||||
"decision_required": wait_for_post_commit_decision,
|
||||
"input_had_residual_model_unit": input_has_residual_model_unit,
|
||||
"response_listen_count": _event_count_after(
|
||||
client.events.events,
|
||||
"response.listen",
|
||||
committed_index,
|
||||
),
|
||||
"response_done_count": _event_count_after(
|
||||
client.events.events,
|
||||
"response.done",
|
||||
committed_index,
|
||||
),
|
||||
},
|
||||
"audio_bytes": len(audio),
|
||||
"audio_chunk_count": len(stream_writer.audio_chunk_paths),
|
||||
"audio_chunk_files": [str(path) for path in stream_writer.audio_chunk_paths],
|
||||
"output_sample_rate_hz": client.events.output_sample_rate_hz,
|
||||
"latency": {
|
||||
"ttft_ms": (
|
||||
round((first_text_at_s - commit_sent_at_s) * 1000, 2) if first_text_at_s is not None else None
|
||||
),
|
||||
"ttfp_ms": (
|
||||
round((first_audio_at_s - commit_sent_at_s) * 1000, 2) if first_audio_at_s is not None else None
|
||||
),
|
||||
"rtf": (
|
||||
round(response_generation_s / audio_duration_s, 4)
|
||||
if response_generation_s is not None and audio_duration_s > 0
|
||||
else None
|
||||
),
|
||||
"response_generation_ms": (
|
||||
round(response_generation_s * 1000, 2) if response_generation_s is not None else None
|
||||
),
|
||||
"text_stream_ms": (
|
||||
round((response_done_at_s - first_text_at_s) * 1000, 2)
|
||||
if response_done_at_s is not None and first_text_at_s is not None
|
||||
else None
|
||||
),
|
||||
"transcript_delta_count": len(transcript_deltas),
|
||||
"audio_duration_s": round(audio_duration_s, 3),
|
||||
"measurement_origin": "input_audio_buffer.commit send",
|
||||
},
|
||||
"timing": timing,
|
||||
"response_ids": client.events.response_ids,
|
||||
"transcript": "".join(transcript_deltas),
|
||||
"errors": errors,
|
||||
"output_dir": str(output_dir),
|
||||
}
|
||||
(output_dir / "result.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="ws://localhost:8099/v1/realtime?duplex=1")
|
||||
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
||||
parser.add_argument("--session-id")
|
||||
parser.add_argument("--input-wav", required=True)
|
||||
parser.add_argument(
|
||||
"--ref-audio",
|
||||
required=True,
|
||||
help=(
|
||||
"Reference WAV for the MiniCPM-o duplex assistant voice. "
|
||||
"This demo matches the official flow by always providing a reference audio clip."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output-dir", default="/tmp/minicpmo_realtime_duplex_demo")
|
||||
parser.add_argument("--chunk-ms", type=int, default=200)
|
||||
parser.add_argument("--timeout-s", type=float, default=60.0)
|
||||
parser.add_argument("--no-realtime-pacing", action="store_true")
|
||||
parser.add_argument("--require-audio", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = asyncio.run(run_demo(parse_args()))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
if not result["ok"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Browser demo for the experimental full-duplex runtime."""
|
||||
|
||||
from .server import build_app
|
||||
|
||||
__all__ = ["build_app"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>MiniCPM-o 4.5 Voice</title>
|
||||
<link rel="stylesheet" href="static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div>
|
||||
<p class="eyebrow">Experimental full-duplex runtime</p>
|
||||
<h1>MiniCPM-o 4.5 Voice</h1>
|
||||
</div>
|
||||
<span id="connectionState" class="status status-offline">Offline</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="call-bar" aria-label="Session controls">
|
||||
<div class="call-actions">
|
||||
<button id="callButton" class="primary" type="button">Start session</button>
|
||||
<button id="muteButton" class="secondary" type="button" disabled>Mute</button>
|
||||
<button id="cameraButton" class="secondary" type="button" disabled>Camera</button>
|
||||
<video id="cameraPreview" muted playsinline style="display:none;width:96px;height:72px;border-radius:8px;object-fit:cover;background:#000"></video>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;margin-top:8px;width:100%">
|
||||
<label style="display:inline-flex;flex-direction:column;gap:4px;font-size:13px;opacity:.85;white-space:nowrap">
|
||||
Prompt preset
|
||||
<select id="promptPreset" style="font-size:13px;padding:4px 6px;border-radius:6px">
|
||||
<option value="omni" selected>Omni (official omni preset)</option>
|
||||
<option value="chinese_call">中文通话 (official audio preset)</option>
|
||||
<option value="english_call">English Call (official audio preset)</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<textarea id="systemPrompt" rows="2" placeholder="System prompt" style="flex:1;font-size:13px;padding:6px 8px;border-radius:6px;resize:vertical">Streaming Omni Conversation.</textarea>
|
||||
</div>
|
||||
<div class="meter" aria-label="Microphone level">
|
||||
<span>Mic</span>
|
||||
<div class="meter-track"><div id="meterFill" class="meter-fill"></div></div>
|
||||
</div>
|
||||
<dl class="runtime-status">
|
||||
<div><dt>Model</dt><dd id="modelState">Idle</dd></div>
|
||||
<div><dt>Playback</dt><dd id="playbackState">Idle</dd></div>
|
||||
<div><dt>Session</dt><dd id="sessionTimer">00:00</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="conversation-section" aria-labelledby="conversationTitle">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live transcript</p>
|
||||
<h2 id="conversationTitle">Conversation</h2>
|
||||
</div>
|
||||
<span class="policy-label">Model-controlled listen / speak</span>
|
||||
</div>
|
||||
<div id="conversation" class="conversation" aria-live="polite">
|
||||
<p id="emptyConversation" class="empty-state">Start a session and speak naturally.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="diagnostics" open>
|
||||
<summary>
|
||||
<span>Event log</span>
|
||||
<span id="eventCount" class="event-count">0 events</span>
|
||||
</summary>
|
||||
<div class="log-toolbar">
|
||||
<span id="runtimeDetail">No active connection</span>
|
||||
<button id="clearLogButton" class="icon-button" type="button" title="Clear event log">Clear</button>
|
||||
</div>
|
||||
<pre id="eventLog" class="event-log" aria-live="polite"></pre>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script>window.FULL_DUPLEX_CONFIG = __FULL_DUPLEX_CONFIG__;</script>
|
||||
<script src="static/app.js?v=__FULL_DUPLEX_APP_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,671 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const config = window.FULL_DUPLEX_CONFIG || {};
|
||||
const callButton = document.getElementById('callButton');
|
||||
const muteButton = document.getElementById('muteButton');
|
||||
const cameraButton = document.getElementById('cameraButton');
|
||||
const cameraPreview = document.getElementById('cameraPreview');
|
||||
const promptPreset = document.getElementById('promptPreset');
|
||||
const systemPromptInput = document.getElementById('systemPrompt');
|
||||
const connectionState = document.getElementById('connectionState');
|
||||
const modelState = document.getElementById('modelState');
|
||||
const playbackState = document.getElementById('playbackState');
|
||||
const sessionTimer = document.getElementById('sessionTimer');
|
||||
const meterFill = document.getElementById('meterFill');
|
||||
const conversation = document.getElementById('conversation');
|
||||
const emptyConversation = document.getElementById('emptyConversation');
|
||||
const eventLog = document.getElementById('eventLog');
|
||||
const eventCount = document.getElementById('eventCount');
|
||||
const runtimeDetail = document.getElementById('runtimeDetail');
|
||||
const clearLogButton = document.getElementById('clearLogButton');
|
||||
|
||||
const INPUT_RATE = 16000;
|
||||
const OUTPUT_RATE = 24000;
|
||||
const SEND_INTERVAL_MS = 200;
|
||||
const ECHO_GUARD_MS = 300;
|
||||
const INITIAL_PLAYBACK_BUFFER_MS = 400;
|
||||
const SESSION_CLOSE_TIMEOUT_MS = 1000;
|
||||
|
||||
// Default prompts mirroring the official MiniCPM-o-Demo presets
|
||||
// (assets/presets/{omni,audio_duplex}/*.yaml).
|
||||
const PROMPT_PRESETS = {
|
||||
omni: 'Streaming Omni Conversation.',
|
||||
chinese_call: '扮演一个具有以上声音特征的助手。请认真、高质量地回复用户的问题。'
|
||||
+ '请用高自然度的方式和用户聊天。你处于双工模式,可以一边听、一边说。'
|
||||
+ '你是由面壁智能开发的人工智能助手:面壁小钢炮。',
|
||||
english_call: 'Replicate the tone and style from the input audio. Your task is to be '
|
||||
+ 'a helpful assistant using this voice pattern. Please answer the user\'s questions '
|
||||
+ 'seriously and in a high quality. Please chat with the user in a high naturalness '
|
||||
+ 'style. You are in duplex mode, where you can listen and speak at the same time.',
|
||||
};
|
||||
|
||||
let socket = null;
|
||||
let mediaStream = null;
|
||||
let captureContext = null;
|
||||
let captureNode = null;
|
||||
let playbackContext = null;
|
||||
let playbackNode = null;
|
||||
let sendTimer = null;
|
||||
let clockTimer = null;
|
||||
let startedAt = 0;
|
||||
let running = false;
|
||||
let muted = false;
|
||||
let assistantActive = false;
|
||||
let captureRate = INPUT_RATE;
|
||||
let cameraStream = null;
|
||||
let cameraTimer = null;
|
||||
let cameraPendingFrame = null;
|
||||
const cameraCanvas = document.createElement('canvas');
|
||||
let playbackRate = OUTPUT_RATE;
|
||||
let pendingCapture = [];
|
||||
let currentResponseId = null;
|
||||
let responseHasAudio = false;
|
||||
let logCount = 0;
|
||||
let liveUserTurn = null;
|
||||
let liveAssistantTurn = null;
|
||||
let sessionCloseResolver = null;
|
||||
|
||||
function staticAssetUrl(path) {
|
||||
const version = String(config.appVersion || '').trim();
|
||||
return version ? `${path}?v=${encodeURIComponent(version)}` : path;
|
||||
}
|
||||
|
||||
if (promptPreset && systemPromptInput) {
|
||||
promptPreset.addEventListener('change', () => {
|
||||
const preset = PROMPT_PRESETS[promptPreset.value];
|
||||
if (preset !== undefined) systemPromptInput.value = preset;
|
||||
});
|
||||
systemPromptInput.addEventListener('input', () => {
|
||||
promptPreset.value = 'custom';
|
||||
});
|
||||
}
|
||||
|
||||
function realtimeUrl() {
|
||||
const url = new URL(config.realtimePath, window.location.href);
|
||||
url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.searchParams.set('duplex', '1');
|
||||
url.searchParams.set('model', config.model || 'openbmb/MiniCPM-o-4_5');
|
||||
url.searchParams.set('minicpmo45_native_duplex', '1');
|
||||
url.searchParams.set('autostart', '0');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setConnection(label, kind) {
|
||||
connectionState.textContent = label;
|
||||
connectionState.className = `status status-${kind}`;
|
||||
}
|
||||
|
||||
function setModel(label) {
|
||||
modelState.textContent = label;
|
||||
}
|
||||
|
||||
function setPlayback(label) {
|
||||
playbackState.textContent = label;
|
||||
}
|
||||
|
||||
function compactEvent(event) {
|
||||
const fields = [];
|
||||
const payload = event.event || event;
|
||||
const responseId = responseIdOf(event);
|
||||
if (responseId) fields.push(`response=${responseId}`);
|
||||
if (event.item_id) fields.push(`item=${event.item_id}`);
|
||||
if (event.code) fields.push(`code=${event.code}`);
|
||||
if (event.response && event.response.status) fields.push(`status=${event.response.status}`);
|
||||
if (payload.played_ms !== undefined) fields.push(`played=${payload.played_ms}ms`);
|
||||
if (payload.committed_ms !== undefined) fields.push(`committed=${payload.committed_ms}ms`);
|
||||
if (payload.history_committed !== undefined) fields.push(`history=${payload.history_committed}`);
|
||||
return fields.join(' ');
|
||||
}
|
||||
|
||||
function appendLog(message, error = false) {
|
||||
const time = new Date().toLocaleTimeString([], { hour12: false });
|
||||
const line = document.createElement('span');
|
||||
if (error) line.className = 'log-error';
|
||||
line.textContent = `${time} ${message}\n`;
|
||||
eventLog.appendChild(line);
|
||||
eventLog.scrollTop = eventLog.scrollHeight;
|
||||
logCount += 1;
|
||||
eventCount.textContent = `${logCount} ${logCount === 1 ? 'event' : 'events'}`;
|
||||
}
|
||||
|
||||
function appendEventLog(event) {
|
||||
const detail = compactEvent(event);
|
||||
appendLog(`${event.type || 'unknown'}${detail ? ` ${detail}` : ''}`, event.type === 'error');
|
||||
}
|
||||
|
||||
function responseIdOf(event) {
|
||||
return event.response_id || (event.response && event.response.id) || null;
|
||||
}
|
||||
|
||||
function ensureTurn(role) {
|
||||
const existing = role === 'user' ? liveUserTurn : liveAssistantTurn;
|
||||
if (existing) return existing;
|
||||
if (emptyConversation) emptyConversation.remove();
|
||||
const row = document.createElement('div');
|
||||
row.className = `turn turn-${role} turn-live`;
|
||||
const label = document.createElement('div');
|
||||
label.className = 'turn-role';
|
||||
label.textContent = role === 'user' ? 'You' : 'Assistant';
|
||||
const text = document.createElement('div');
|
||||
text.className = 'turn-text';
|
||||
row.append(label, text);
|
||||
conversation.appendChild(row);
|
||||
conversation.scrollTop = conversation.scrollHeight;
|
||||
const turn = { row, text, value: '' };
|
||||
if (role === 'user') liveUserTurn = turn;
|
||||
else liveAssistantTurn = turn;
|
||||
return turn;
|
||||
}
|
||||
|
||||
function addTranscript(role, delta) {
|
||||
if (!delta) return;
|
||||
const turn = ensureTurn(role);
|
||||
turn.value += delta;
|
||||
turn.text.textContent = turn.value;
|
||||
conversation.scrollTop = conversation.scrollHeight;
|
||||
}
|
||||
|
||||
function finishTranscript(role, finalText = '') {
|
||||
const turn = role === 'user' ? liveUserTurn : liveAssistantTurn;
|
||||
if (!turn && !finalText) return;
|
||||
const current = turn || ensureTurn(role);
|
||||
if (finalText) {
|
||||
current.value = finalText;
|
||||
current.text.textContent = finalText;
|
||||
}
|
||||
current.row.classList.remove('turn-live');
|
||||
if (role === 'user') liveUserTurn = null;
|
||||
else liveAssistantTurn = null;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function int16ToBase64(pcm) {
|
||||
return bytesToBase64(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
||||
}
|
||||
|
||||
function base64ToBytes(encoded) {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function resampleInt16(input, sourceRate, targetRate) {
|
||||
if (sourceRate === targetRate) return input;
|
||||
const ratio = sourceRate / targetRate;
|
||||
const output = new Int16Array(Math.floor(input.length / ratio));
|
||||
for (let index = 0; index < output.length; index += 1) {
|
||||
const start = Math.floor(index * ratio);
|
||||
const end = Math.max(start + 1, Math.min(input.length, Math.floor((index + 1) * ratio)));
|
||||
let sum = 0;
|
||||
for (let source = start; source < end; source += 1) sum += input[source];
|
||||
output[index] = sum / (end - start);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function decodeAudioDelta(event) {
|
||||
const encoded = event.delta || (event.response && event.response.audio);
|
||||
if (!encoded) return null;
|
||||
const bytes = base64ToBytes(encoded);
|
||||
const format = String(event.format || event.audio_format || 'pcm16').toLowerCase();
|
||||
const sourceRate = Number(event.sample_rate_hz || event.sample_rate || OUTPUT_RATE);
|
||||
if (format.includes('f32')) {
|
||||
const floats = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
||||
const pcm = new Int16Array(floats.length);
|
||||
for (let index = 0; index < floats.length; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, floats[index]));
|
||||
pcm[index] = sample < 0 ? sample * 32768 : sample * 32767;
|
||||
}
|
||||
return { pcm, sourceRate };
|
||||
}
|
||||
if (format.includes('wav')) {
|
||||
const decoded = await playbackContext.decodeAudioData(bytes.buffer.slice(0));
|
||||
const channel = decoded.getChannelData(0);
|
||||
const pcm = new Int16Array(channel.length);
|
||||
for (let index = 0; index < channel.length; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, channel[index]));
|
||||
pcm[index] = sample < 0 ? sample * 32768 : sample * 32767;
|
||||
}
|
||||
return { pcm, sourceRate: decoded.sampleRate };
|
||||
}
|
||||
return {
|
||||
pcm: new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2)),
|
||||
sourceRate,
|
||||
};
|
||||
}
|
||||
|
||||
function updateMeter(pcm) {
|
||||
let peak = 0;
|
||||
for (let index = 0; index < pcm.length; index += 8) peak = Math.max(peak, Math.abs(pcm[index]));
|
||||
meterFill.style.width = `${Math.min(100, (peak / 32768) * 150).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function microphoneUploadEnabled() {
|
||||
return running && !muted;
|
||||
}
|
||||
|
||||
function flushCapture() {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || pendingCapture.length === 0) return;
|
||||
if (!microphoneUploadEnabled()) {
|
||||
pendingCapture = [];
|
||||
return;
|
||||
}
|
||||
const length = pendingCapture.reduce((total, chunk) => total + chunk.length, 0);
|
||||
const merged = new Int16Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of pendingCapture) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
pendingCapture = [];
|
||||
const pcm = resampleInt16(merged, captureRate, INPUT_RATE);
|
||||
const appendEvent = {
|
||||
type: 'input_audio_buffer.append',
|
||||
audio: int16ToBase64(pcm),
|
||||
format: 'pcm16',
|
||||
sample_rate_hz: INPUT_RATE,
|
||||
};
|
||||
// Omni duplex: ~1 fps camera frame rides the audio append (official
|
||||
// MiniCPM-o-Demo contract: one base64 JPEG per ~1 s chunk).
|
||||
if (cameraPendingFrame) {
|
||||
appendEvent.video_frames = [cameraPendingFrame];
|
||||
cameraPendingFrame = null;
|
||||
}
|
||||
socket.send(JSON.stringify(appendEvent));
|
||||
}
|
||||
|
||||
function beginAssistant(responseId) {
|
||||
currentResponseId = responseId || currentResponseId;
|
||||
responseHasAudio = false;
|
||||
assistantActive = true;
|
||||
setModel('Speaking');
|
||||
}
|
||||
|
||||
function feedPlayback(decoded, responseId) {
|
||||
if (!decoded || !decoded.pcm || decoded.pcm.length === 0 || !playbackNode) return;
|
||||
const pcm = resampleInt16(decoded.pcm, decoded.sourceRate, playbackRate);
|
||||
responseHasAudio = true;
|
||||
assistantActive = true;
|
||||
setPlayback('Buffering');
|
||||
playbackNode.port.postMessage({
|
||||
type: 'audio',
|
||||
pcm,
|
||||
responseId: responseId || currentResponseId,
|
||||
initialBufferMs: INITIAL_PLAYBACK_BUFFER_MS,
|
||||
}, [pcm.buffer]);
|
||||
}
|
||||
|
||||
function requestPlaybackDrain(responseId) {
|
||||
if (!playbackNode) return;
|
||||
playbackNode.port.postMessage({ type: 'drain', responseId: responseId || currentResponseId });
|
||||
}
|
||||
|
||||
function sendPlaybackAck(responseId, playedMs) {
|
||||
if (!responseId || !socket || socket.readyState !== WebSocket.OPEN || playedMs <= 0) {
|
||||
if (!responseId && playedMs > 0) appendLog('playback ack skipped: missing response id', true);
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify({
|
||||
type: 'playback.ack',
|
||||
response_id: responseId,
|
||||
item_id: `item_${responseId}`,
|
||||
played_ms: playedMs,
|
||||
committed_ms: playedMs,
|
||||
}));
|
||||
}
|
||||
|
||||
function playbackDrained(message) {
|
||||
const responseId = message.responseId || currentResponseId;
|
||||
sendPlaybackAck(responseId, Number(message.playedMs) || 0);
|
||||
setPlayback('Idle');
|
||||
if (message.underrunMs > 0) {
|
||||
appendLog(`playback underrun ${message.underrunMs} ms`);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
assistantActive = false;
|
||||
currentResponseId = null;
|
||||
responseHasAudio = false;
|
||||
if (running) setModel('Listening');
|
||||
}, ECHO_GUARD_MS);
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
appendEventLog(event);
|
||||
const responseId = responseIdOf(event);
|
||||
switch (event.type) {
|
||||
case 'session.created':
|
||||
case 'session.updated':
|
||||
setConnection('Connected', 'online');
|
||||
setModel('Listening');
|
||||
break;
|
||||
case 'response.listen':
|
||||
assistantActive = false;
|
||||
setModel('Listening');
|
||||
break;
|
||||
case 'response.created':
|
||||
case 'response.speak':
|
||||
beginAssistant(responseId);
|
||||
break;
|
||||
case 'response.audio.delta':
|
||||
currentResponseId = responseId || currentResponseId;
|
||||
assistantActive = true;
|
||||
setModel('Speaking');
|
||||
decodeAudioDelta(event)
|
||||
.then((decoded) => feedPlayback(decoded, responseId))
|
||||
.catch((error) => appendLog(`audio decode failed: ${error.message || error}`, true));
|
||||
break;
|
||||
case 'response.audio.done':
|
||||
requestPlaybackDrain(responseId);
|
||||
break;
|
||||
case 'response.audio_transcript.delta':
|
||||
addTranscript('assistant', event.delta || '');
|
||||
break;
|
||||
case 'response.audio_transcript.done':
|
||||
finishTranscript('assistant', event.transcript || '');
|
||||
break;
|
||||
case 'conversation.item.input_audio_transcription.delta':
|
||||
addTranscript('user', event.delta || '');
|
||||
break;
|
||||
case 'conversation.item.input_audio_transcription.completed':
|
||||
finishTranscript('user', event.transcript || '');
|
||||
break;
|
||||
case 'response.done':
|
||||
finishTranscript('assistant');
|
||||
if (!responseHasAudio) requestPlaybackDrain(responseId);
|
||||
break;
|
||||
case 'playback.acknowledged':
|
||||
{
|
||||
const acknowledgement = event.event || event;
|
||||
runtimeDetail.textContent = `Playback committed ${acknowledgement.committed_ms || 0} ms`;
|
||||
}
|
||||
break;
|
||||
case 'session.closed':
|
||||
if (sessionCloseResolver) sessionCloseResolver();
|
||||
sessionCloseResolver = null;
|
||||
break;
|
||||
case 'error':
|
||||
setConnection('Error', 'error');
|
||||
runtimeDetail.textContent = String(event.error || event.code || 'Server error');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPlayback() {
|
||||
playbackContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: OUTPUT_RATE });
|
||||
playbackRate = playbackContext.sampleRate;
|
||||
await playbackContext.audioWorklet.addModule(staticAssetUrl('static/playback_worklet.js'));
|
||||
playbackNode = new AudioWorkletNode(playbackContext, 'fullduplex-pcm-playback');
|
||||
playbackNode.port.onmessage = (message) => {
|
||||
if (message.data.type === 'playback-started') setPlayback('Playing');
|
||||
else if (message.data.type === 'playback-drained') playbackDrained(message.data);
|
||||
else if (message.data.type === 'playback-underrun') {
|
||||
runtimeDetail.textContent = `Playback underrun ${message.data.underrunMs || 0} ms`;
|
||||
}
|
||||
};
|
||||
playbackNode.connect(playbackContext.destination);
|
||||
await playbackContext.resume();
|
||||
}
|
||||
|
||||
async function openCapture() {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
sampleRate: { ideal: INPUT_RATE },
|
||||
},
|
||||
});
|
||||
try {
|
||||
captureContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: INPUT_RATE });
|
||||
} catch (_error) {
|
||||
captureContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
captureRate = captureContext.sampleRate;
|
||||
await captureContext.audioWorklet.addModule(staticAssetUrl('static/pcm_worklet.js'));
|
||||
const source = captureContext.createMediaStreamSource(mediaStream);
|
||||
captureNode = new AudioWorkletNode(captureContext, 'fullduplex-pcm-capture');
|
||||
captureNode.port.onmessage = (message) => {
|
||||
const pcm = new Int16Array(message.data);
|
||||
updateMeter(pcm);
|
||||
if (microphoneUploadEnabled()) pendingCapture.push(pcm);
|
||||
};
|
||||
const silentSink = captureContext.createGain();
|
||||
silentSink.gain.value = 0;
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(silentSink).connect(captureContext.destination);
|
||||
await captureContext.resume();
|
||||
}
|
||||
|
||||
function openSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = realtimeUrl();
|
||||
socket = new WebSocket(url);
|
||||
let settled = false;
|
||||
socket.onopen = () => {
|
||||
settled = true;
|
||||
const extraBody = {
|
||||
auto_response: true,
|
||||
minicpmo45_native_duplex: true,
|
||||
};
|
||||
const session = {
|
||||
modalities: ['audio', 'text'],
|
||||
voice: 'default',
|
||||
extra_body: extraBody,
|
||||
};
|
||||
// Reference voice for TTS cloning, provided by the server via
|
||||
// --ref-audio (mirrors the official demo's default ref audio).
|
||||
if (config.refAudio) session.ref_audio = config.refAudio;
|
||||
const instructions = systemPromptInput ? systemPromptInput.value.trim() : '';
|
||||
if (instructions) session.instructions = instructions;
|
||||
socket.send(JSON.stringify({ type: 'session.update', session }));
|
||||
runtimeDetail.textContent = `${captureRate} Hz capture / ${playbackRate} Hz playback`;
|
||||
appendLog(`websocket open ${url}`);
|
||||
resolve();
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
if (typeof message.data !== 'string') return;
|
||||
try {
|
||||
handleEvent(JSON.parse(message.data));
|
||||
} catch (error) {
|
||||
appendLog(`invalid server event: ${error.message || error}`, true);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`WebSocket connection failed: ${url}`));
|
||||
}
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
appendLog(`websocket closed code=${event.code}`);
|
||||
if (running) {
|
||||
setConnection('Disconnected', 'error');
|
||||
stopSession();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatElapsed(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function startClock() {
|
||||
startedAt = Date.now();
|
||||
clockTimer = window.setInterval(() => {
|
||||
sessionTimer.textContent = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function startSession() {
|
||||
if (running) return;
|
||||
callButton.disabled = true;
|
||||
setConnection('Connecting', 'connecting');
|
||||
runtimeDetail.textContent = 'Requesting microphone access';
|
||||
try {
|
||||
await openPlayback();
|
||||
await openCapture();
|
||||
await openSocket();
|
||||
running = true;
|
||||
muted = false;
|
||||
assistantActive = false;
|
||||
sendTimer = window.setInterval(flushCapture, SEND_INTERVAL_MS);
|
||||
startClock();
|
||||
callButton.textContent = 'End session';
|
||||
callButton.classList.add('is-active');
|
||||
muteButton.disabled = false;
|
||||
cameraButton.disabled = false;
|
||||
setConnection('Connected', 'online');
|
||||
setModel('Listening');
|
||||
appendLog('session started');
|
||||
} catch (error) {
|
||||
appendLog(`start failed: ${error.message || error}`, true);
|
||||
setConnection('Error', 'error');
|
||||
runtimeDetail.textContent = String(error.message || error);
|
||||
await stopSession();
|
||||
} finally {
|
||||
callButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
if (cameraStream) return;
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
||||
cameraPreview.srcObject = cameraStream;
|
||||
cameraPreview.style.display = '';
|
||||
await cameraPreview.play().catch(() => {});
|
||||
// Official omni-duplex cadence: one JPEG (quality 0.7) per ~1 s chunk,
|
||||
// no client-side resize (the server normalizes at scale_resolution=448).
|
||||
cameraTimer = window.setInterval(() => {
|
||||
if (!cameraStream || cameraPreview.videoWidth === 0) return;
|
||||
cameraCanvas.width = cameraPreview.videoWidth;
|
||||
cameraCanvas.height = cameraPreview.videoHeight;
|
||||
cameraCanvas.getContext('2d').drawImage(cameraPreview, 0, 0);
|
||||
cameraPendingFrame = cameraCanvas.toDataURL('image/jpeg', 0.7).split(',')[1];
|
||||
}, 1000);
|
||||
cameraButton.textContent = 'Camera off';
|
||||
cameraButton.classList.add('is-active');
|
||||
appendLog('camera on (1 fps omni frames)');
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (cameraTimer !== null) clearInterval(cameraTimer);
|
||||
cameraTimer = null;
|
||||
if (cameraStream) {
|
||||
for (const track of cameraStream.getTracks()) track.stop();
|
||||
}
|
||||
cameraStream = null;
|
||||
cameraPendingFrame = null;
|
||||
cameraPreview.srcObject = null;
|
||||
cameraPreview.style.display = 'none';
|
||||
cameraButton.textContent = 'Camera';
|
||||
cameraButton.classList.remove('is-active');
|
||||
}
|
||||
|
||||
cameraButton.addEventListener('click', () => {
|
||||
if (cameraStream) {
|
||||
stopCamera();
|
||||
appendLog('camera off');
|
||||
return;
|
||||
}
|
||||
startCamera().catch((error) => appendLog(`camera failed: ${error.message || error}`, true));
|
||||
});
|
||||
|
||||
function waitForSessionClosed(targetSocket, timeoutMs) {
|
||||
if (!targetSocket || targetSocket.readyState !== WebSocket.OPEN) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (sessionCloseResolver === finish) sessionCloseResolver = null;
|
||||
resolve();
|
||||
};
|
||||
sessionCloseResolver = finish;
|
||||
window.setTimeout(finish, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopSession({ terminal = true } = {}) {
|
||||
running = false;
|
||||
assistantActive = false;
|
||||
pendingCapture = [];
|
||||
if (sendTimer !== null) clearInterval(sendTimer);
|
||||
if (clockTimer !== null) clearInterval(clockTimer);
|
||||
sendTimer = null;
|
||||
clockTimer = null;
|
||||
if (socket) {
|
||||
const closingSocket = socket;
|
||||
socket = null;
|
||||
closingSocket.onclose = null;
|
||||
if (terminal && closingSocket.readyState === WebSocket.OPEN) {
|
||||
const closed = waitForSessionClosed(closingSocket, SESSION_CLOSE_TIMEOUT_MS);
|
||||
closingSocket.send(JSON.stringify({ type: 'session.close' }));
|
||||
await closed;
|
||||
}
|
||||
closingSocket.close(1000, 'client stop');
|
||||
}
|
||||
if (playbackNode) playbackNode.port.postMessage({ type: 'clear' });
|
||||
if (mediaStream) {
|
||||
for (const track of mediaStream.getTracks()) track.stop();
|
||||
}
|
||||
mediaStream = null;
|
||||
stopCamera();
|
||||
cameraButton.disabled = true;
|
||||
if (captureContext) await captureContext.close().catch(() => {});
|
||||
if (playbackContext) await playbackContext.close().catch(() => {});
|
||||
captureContext = null;
|
||||
captureNode = null;
|
||||
playbackContext = null;
|
||||
playbackNode = null;
|
||||
currentResponseId = null;
|
||||
responseHasAudio = false;
|
||||
meterFill.style.width = '0%';
|
||||
sessionTimer.textContent = '00:00';
|
||||
callButton.textContent = 'Start session';
|
||||
callButton.classList.remove('is-active');
|
||||
muteButton.textContent = 'Mute';
|
||||
muteButton.classList.remove('is-active');
|
||||
muteButton.disabled = true;
|
||||
setConnection('Offline', 'offline');
|
||||
setModel('Idle');
|
||||
setPlayback('Idle');
|
||||
if (runtimeDetail.textContent.startsWith('Playback committed')) return;
|
||||
if (!runtimeDetail.textContent.startsWith('start failed')) runtimeDetail.textContent = 'No active connection';
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!running) return;
|
||||
muted = !muted;
|
||||
pendingCapture = [];
|
||||
muteButton.textContent = muted ? 'Unmute' : 'Mute';
|
||||
muteButton.classList.toggle('is-active', muted);
|
||||
appendLog(muted ? 'microphone muted' : 'microphone unmuted');
|
||||
}
|
||||
|
||||
callButton.addEventListener('click', () => {
|
||||
if (running) stopSession();
|
||||
else startSession();
|
||||
});
|
||||
muteButton.addEventListener('click', toggleMute);
|
||||
clearLogButton.addEventListener('click', () => {
|
||||
eventLog.textContent = '';
|
||||
logCount = 0;
|
||||
eventCount.textContent = '0 events';
|
||||
});
|
||||
window.addEventListener('beforeunload', () => { stopSession({ terminal: false }); });
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
class FullDuplexPcmCapture extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const channel = inputs[0] && inputs[0][0];
|
||||
if (!channel || channel.length === 0) return true;
|
||||
const pcm = new Int16Array(channel.length);
|
||||
for (let index = 0; index < channel.length; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, channel[index]));
|
||||
pcm[index] = sample < 0 ? sample * 32768 : sample * 32767;
|
||||
}
|
||||
this.port.postMessage(pcm.buffer, [pcm.buffer]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('fullduplex-pcm-capture', FullDuplexPcmCapture);
|
||||
@@ -0,0 +1,153 @@
|
||||
class FullDuplexPcmPlayback extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.queue = [];
|
||||
this.offset = 0;
|
||||
this.playedFrames = 0;
|
||||
this.underrunFrames = 0;
|
||||
this.drain = null;
|
||||
this.started = false;
|
||||
this.activeResponseId = null;
|
||||
this.initialBufferFrames = Math.round(sampleRate * 0.2);
|
||||
this.bufferWaitFrames = this.initialBufferFrames;
|
||||
this.rebuffering = false;
|
||||
this.fadeFrames = Math.max(1, Math.round(sampleRate * 0.005));
|
||||
this.fadeInFrames = 0;
|
||||
this.port.onmessage = (event) => this.handleMessage(event.data || {});
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
if (message.type === 'audio' && message.pcm) {
|
||||
const wasEmpty = this.queue.length === 0;
|
||||
if (!this.started && !this.activeResponseId) {
|
||||
this.activeResponseId = message.responseId || null;
|
||||
}
|
||||
if (!this.started && Number.isFinite(message.initialBufferMs)) {
|
||||
this.initialBufferFrames = Math.max(0, Math.round((sampleRate * message.initialBufferMs) / 1000));
|
||||
}
|
||||
this.queue.push(message.pcm);
|
||||
if (!this.started && wasEmpty && !this.rebuffering) {
|
||||
this.bufferWaitFrames = this.initialBufferFrames;
|
||||
}
|
||||
} else if (message.type === 'drain') {
|
||||
this.drain = { responseId: message.responseId || null };
|
||||
if (!this.started && this.bufferedFrames() > 0) {
|
||||
this.bufferWaitFrames = 0;
|
||||
this.startPlayback();
|
||||
}
|
||||
this.notifyIfDrained();
|
||||
} else if (message.type === 'clear') {
|
||||
this.queue = [];
|
||||
this.offset = 0;
|
||||
this.playedFrames = 0;
|
||||
this.underrunFrames = 0;
|
||||
this.drain = null;
|
||||
this.started = false;
|
||||
this.activeResponseId = null;
|
||||
this.bufferWaitFrames = this.initialBufferFrames;
|
||||
this.rebuffering = false;
|
||||
this.fadeInFrames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bufferedFrames() {
|
||||
return this.queue.reduce((total, pcm, index) => (
|
||||
total + pcm.length - (index === 0 ? this.offset : 0)
|
||||
), 0);
|
||||
}
|
||||
|
||||
startPlayback() {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.fadeInFrames = this.fadeFrames;
|
||||
if (this.playedFrames === 0) {
|
||||
this.port.postMessage({ type: 'playback-started', responseId: this.activeResponseId });
|
||||
}
|
||||
}
|
||||
|
||||
notifyIfDrained() {
|
||||
if (!this.drain || this.queue.length > 0) return;
|
||||
this.port.postMessage({
|
||||
type: 'playback-drained',
|
||||
responseId: this.drain.responseId,
|
||||
playedMs: Math.round((this.playedFrames * 1000) / sampleRate),
|
||||
underrunMs: Math.round((this.underrunFrames * 1000) / sampleRate),
|
||||
});
|
||||
this.playedFrames = 0;
|
||||
this.underrunFrames = 0;
|
||||
this.drain = null;
|
||||
this.started = false;
|
||||
this.activeResponseId = null;
|
||||
this.bufferWaitFrames = this.initialBufferFrames;
|
||||
this.rebuffering = false;
|
||||
this.fadeInFrames = 0;
|
||||
}
|
||||
|
||||
reportUnderrun() {
|
||||
this.port.postMessage({
|
||||
type: 'playback-underrun',
|
||||
responseId: this.activeResponseId,
|
||||
underrunMs: Math.round((this.underrunFrames * 1000) / sampleRate),
|
||||
});
|
||||
}
|
||||
|
||||
process(_inputs, outputs) {
|
||||
const output = outputs[0][0];
|
||||
output.fill(0);
|
||||
if (!this.started) {
|
||||
if (this.rebuffering && !this.drain) {
|
||||
this.underrunFrames += output.length;
|
||||
this.reportUnderrun();
|
||||
}
|
||||
if (this.queue.length > 0 && this.bufferWaitFrames > 0) {
|
||||
this.bufferWaitFrames = Math.max(0, this.bufferWaitFrames - output.length);
|
||||
return true;
|
||||
}
|
||||
if (this.queue.length > 0) {
|
||||
this.startPlayback();
|
||||
this.rebuffering = false;
|
||||
}
|
||||
}
|
||||
if (!this.started) {
|
||||
this.notifyIfDrained();
|
||||
return true;
|
||||
}
|
||||
let target = 0;
|
||||
while (target < output.length && this.queue.length > 0) {
|
||||
const pcm = this.queue[0];
|
||||
const count = Math.min(output.length - target, pcm.length - this.offset);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
let sample = pcm[this.offset + index] / 32768;
|
||||
if (this.fadeInFrames > 0) {
|
||||
const elapsed = this.fadeFrames - this.fadeInFrames;
|
||||
sample *= elapsed / this.fadeFrames;
|
||||
this.fadeInFrames -= 1;
|
||||
}
|
||||
output[target + index] = sample;
|
||||
}
|
||||
target += count;
|
||||
this.offset += count;
|
||||
this.playedFrames += count;
|
||||
if (this.offset >= pcm.length) {
|
||||
this.queue.shift();
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
if (target < output.length && !this.drain) {
|
||||
const fadeCount = Math.min(target, this.fadeFrames);
|
||||
for (let index = 0; index < fadeCount; index += 1) {
|
||||
output[target - fadeCount + index] *= (fadeCount - index - 1) / fadeCount;
|
||||
}
|
||||
this.underrunFrames += output.length - target;
|
||||
this.reportUnderrun();
|
||||
this.started = false;
|
||||
this.rebuffering = true;
|
||||
this.bufferWaitFrames = this.initialBufferFrames;
|
||||
this.fadeInFrames = 0;
|
||||
}
|
||||
this.notifyIfDrained();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('fullduplex-pcm-playback', FullDuplexPcmPlayback);
|
||||
@@ -0,0 +1,127 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #20252b;
|
||||
background: #f4f6f7;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body { margin: 0; min-width: 320px; }
|
||||
|
||||
button, summary { font: inherit; }
|
||||
|
||||
button:focus-visible, summary:focus-visible {
|
||||
outline: 3px solid rgba(21, 101, 192, .25);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
min-height: 104px;
|
||||
padding: 24px clamp(20px, 5vw, 64px);
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d9dee3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 24px; line-height: 1.2; letter-spacing: 0; }
|
||||
h2 { font-size: 18px; line-height: 1.3; letter-spacing: 0; }
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 5px;
|
||||
color: #68717b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
main { width: min(1040px, calc(100% - 40px)); margin: 28px auto 48px; }
|
||||
|
||||
.status {
|
||||
min-width: 88px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-offline { color: #616971; background: #f0f2f3; border-color: #cbd1d6; }
|
||||
.status-connecting { color: #8a4b00; background: #fff5df; border-color: #e6b76a; }
|
||||
.status-online { color: #176a3a; background: #e9f7ef; border-color: #7dc49b; }
|
||||
.status-error { color: #a12222; background: #fff0f0; border-color: #df8f8f; }
|
||||
|
||||
.call-bar {
|
||||
min-height: 86px;
|
||||
padding: 16px 18px;
|
||||
background: #252b31;
|
||||
color: #ffffff;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(150px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.call-actions { display: flex; gap: 8px; }
|
||||
button { border-radius: 5px; border: 1px solid transparent; cursor: pointer; }
|
||||
button:disabled { opacity: .46; cursor: default; }
|
||||
.primary { padding: 10px 15px; color: #ffffff; background: #16834b; font-weight: 700; }
|
||||
.primary.is-active { background: #b43636; }
|
||||
.secondary { padding: 10px 13px; color: #ffffff; background: #39414a; border-color: #59636d; }
|
||||
.secondary.is-active { color: #2b2525; background: #ffc9c9; border-color: #e58f8f; }
|
||||
|
||||
.meter { min-width: 0; display: flex; align-items: center; gap: 10px; color: #c8cfd6; font-size: 12px; }
|
||||
.meter-track { height: 8px; flex: 1; overflow: hidden; background: #444d56; border-radius: 4px; }
|
||||
.meter-fill { width: 0; height: 100%; background: #54bd7d; transition: width 70ms linear; }
|
||||
|
||||
.runtime-status { margin: 0; display: flex; gap: 24px; }
|
||||
.runtime-status div { min-width: 68px; }
|
||||
.runtime-status dt { color: #9fa8b1; font-size: 10px; text-transform: uppercase; }
|
||||
.runtime-status dd { margin: 3px 0 0; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.conversation-section { margin-top: 22px; padding: 22px 0 10px; }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 16px; padding: 0 2px 14px; border-bottom: 1px solid #cfd5da; }
|
||||
.policy-label { color: #5e6871; font-size: 12px; }
|
||||
.conversation { min-height: 260px; max-height: 440px; overflow-y: auto; padding: 18px 2px; }
|
||||
.empty-state { color: #7a838b; font-size: 14px; }
|
||||
|
||||
.turn { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid #e0e4e7; }
|
||||
.turn-role { padding-top: 2px; color: #68717b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
|
||||
.turn-user .turn-role { color: #1565c0; }
|
||||
.turn-assistant .turn-role { color: #a45117; }
|
||||
.turn-text { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.55; }
|
||||
.turn-live .turn-text::after { content: ""; display: inline-block; width: 6px; height: 14px; margin-left: 4px; background: #9098a0; vertical-align: -2px; animation: blink 1s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.diagnostics { margin-top: 18px; border-top: 1px solid #bfc6cc; border-bottom: 1px solid #bfc6cc; background: #ffffff; }
|
||||
.diagnostics summary { padding: 13px 14px; display: flex; justify-content: space-between; cursor: pointer; font-weight: 700; list-style: none; }
|
||||
.diagnostics summary::-webkit-details-marker { display: none; }
|
||||
.event-count { color: #6f7880; font-size: 12px; font-weight: 500; }
|
||||
.log-toolbar { min-height: 40px; padding: 7px 12px; border-top: 1px solid #e0e4e7; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #69727a; font-size: 12px; }
|
||||
.icon-button { padding: 5px 9px; color: #394149; background: #f4f6f7; border-color: #cdd3d8; }
|
||||
.event-log { height: 200px; margin: 0; padding: 12px 14px; overflow: auto; background: #171b1f; color: #d9e0e6; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
|
||||
.log-error { color: #ff9f9f; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-header { min-height: 88px; padding: 18px 20px; }
|
||||
h1 { font-size: 20px; }
|
||||
main { width: min(100% - 24px, 620px); margin-top: 16px; }
|
||||
.call-bar { grid-template-columns: 1fr; gap: 15px; }
|
||||
.call-actions button { flex: 1; }
|
||||
.runtime-status { justify-content: space-between; gap: 10px; }
|
||||
.section-heading { align-items: start; flex-direction: column; gap: 7px; }
|
||||
.conversation { min-height: 230px; }
|
||||
.turn { grid-template-columns: 72px minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Static host and same-origin Realtime proxy for the full-duplex demo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import uvicorn
|
||||
import websockets
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
APP_DIR = Path(__file__).parent / "app"
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
|
||||
|
||||
def _join_ws_url(base: str, path: str, query: str) -> str:
|
||||
return base.rstrip("/") + path + (("?" + query) if query else "")
|
||||
|
||||
|
||||
async def _pump_client_to_backend(client: WebSocket, backend) -> None:
|
||||
try:
|
||||
while True:
|
||||
message = await client.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
await backend.close()
|
||||
return
|
||||
if message.get("text") is not None:
|
||||
await backend.send(message["text"])
|
||||
elif message.get("bytes") is not None:
|
||||
await backend.send(message["bytes"])
|
||||
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
||||
with contextlib.suppress(Exception):
|
||||
await backend.close()
|
||||
|
||||
|
||||
async def _pump_backend_to_client(client: WebSocket, backend) -> None:
|
||||
try:
|
||||
async for message in backend:
|
||||
if isinstance(message, bytes):
|
||||
await client.send_bytes(message)
|
||||
else:
|
||||
await client.send_text(message)
|
||||
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
if "websocket.send" in str(exc) and "websocket.close" in str(exc):
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def _expected_proxy_close(exc: BaseException) -> bool:
|
||||
if isinstance(exc, (WebSocketDisconnect, websockets.ConnectionClosed, asyncio.CancelledError)):
|
||||
return True
|
||||
return isinstance(exc, RuntimeError) and "websocket.send" in str(exc) and "websocket.close" in str(exc)
|
||||
|
||||
|
||||
def build_app(
|
||||
*,
|
||||
ws_backend: str = "ws://127.0.0.1:8099",
|
||||
model: str = "openbmb/MiniCPM-o-4_5",
|
||||
public_realtime_url: str | None = None,
|
||||
ref_audio: str | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="Experimental Full-Duplex Web Demo")
|
||||
index_path = APP_DIR / "index.html"
|
||||
app_version_hash = hashlib.sha256()
|
||||
for asset_path in (
|
||||
STATIC_DIR / "app.js",
|
||||
STATIC_DIR / "pcm_worklet.js",
|
||||
STATIC_DIR / "playback_worklet.js",
|
||||
):
|
||||
app_version_hash.update(asset_path.read_bytes())
|
||||
app_version = app_version_hash.hexdigest()[:12]
|
||||
|
||||
ref_audio_uri: str | None = None
|
||||
if ref_audio:
|
||||
ref_path = Path(ref_audio)
|
||||
if not ref_path.is_file():
|
||||
raise SystemExit(f"--ref-audio not found: {ref_audio}")
|
||||
encoded = base64.b64encode(ref_path.read_bytes()).decode("ascii")
|
||||
ref_audio_uri = f"data:audio/wav;base64,{encoded}"
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> HTMLResponse:
|
||||
config = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"realtimePath": public_realtime_url or "v1/realtime",
|
||||
"refAudio": ref_audio_uri,
|
||||
"appVersion": app_version,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
)
|
||||
html = (
|
||||
index_path.read_text(encoding="utf-8")
|
||||
.replace("__FULL_DUPLEX_CONFIG__", config)
|
||||
.replace("__FULL_DUPLEX_APP_VERSION__", app_version)
|
||||
)
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> Response:
|
||||
return Response(content="ok", media_type="text/plain")
|
||||
|
||||
@app.websocket("/v1/realtime")
|
||||
async def realtime_proxy(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
query = urlencode(websocket.query_params.multi_items())
|
||||
backend_url = _join_ws_url(ws_backend, "/v1/realtime", query)
|
||||
logger.info("Proxying Realtime WebSocket to %s", backend_url)
|
||||
try:
|
||||
async with websockets.connect(
|
||||
backend_url,
|
||||
max_size=64 * 1024 * 1024,
|
||||
) as backend:
|
||||
tasks = {
|
||||
asyncio.create_task(_pump_client_to_backend(websocket, backend)),
|
||||
asyncio.create_task(_pump_backend_to_client(websocket, backend)),
|
||||
}
|
||||
done, pending = await asyncio.wait(
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
for result in await asyncio.gather(*done, return_exceptions=True):
|
||||
if isinstance(result, BaseException) and not _expected_proxy_close(result):
|
||||
raise result
|
||||
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Realtime WebSocket proxy failed")
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close(code=1011)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=7862)
|
||||
parser.add_argument("--ws-backend", default="ws://127.0.0.1:8099")
|
||||
parser.add_argument(
|
||||
"--public-realtime-url",
|
||||
help="Browser-visible ws:// or wss:// Realtime URL; defaults to the same-origin proxy.",
|
||||
)
|
||||
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
||||
parser.add_argument(
|
||||
"--ref-audio",
|
||||
required=True,
|
||||
help=(
|
||||
"Reference voice wav for TTS voice cloning, e.g. the "
|
||||
"official MiniCPM-o-Demo assets/ref_audio/ref_minicpm_signature.wav"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
uvicorn.run(
|
||||
build_app(
|
||||
ws_backend=args.ws_backend,
|
||||
model=args.model,
|
||||
public_realtime_url=args.public_realtime_url,
|
||||
ref_audio=args.ref_audio,
|
||||
),
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_importing_scheduler_output_does_not_eagerly_import_scheduler_implementations() -> None:
|
||||
package_init = Path(__file__).parents[3] / "vllm_omni/core/sched/__init__.py"
|
||||
module = ast.parse(package_init.read_text())
|
||||
eager_modules = {
|
||||
node.module
|
||||
for node in ast.walk(module)
|
||||
if isinstance(node, ast.ImportFrom)
|
||||
and node.level == 1
|
||||
and node.module in {"omni_ar_scheduler", "omni_generation_scheduler"}
|
||||
}
|
||||
|
||||
assert eager_modules == set()
|
||||
assert any(isinstance(node, ast.FunctionDef) and node.name == "__getattr__" for node in module.body)
|
||||
@@ -677,6 +677,28 @@ def test_finish_requests_removes_zombies_from_chunk_waiting_deques(build_adapter
|
||||
assert "req-zombie" not in adapter.finished_requests
|
||||
|
||||
|
||||
def test_finish_requests_releases_active_stream_slot(build_adapter):
|
||||
adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=1)
|
||||
aborted = _req("req-aborted", RequestStatus.RUNNING)
|
||||
waiting = _req("req-waiting", RequestStatus.WAITING)
|
||||
waiting_queue = DummyWaitingQueue([waiting])
|
||||
running_queue = []
|
||||
adapter._active_streams[aborted.request_id] = aborted
|
||||
adapter._held_non_active.append(aborted)
|
||||
|
||||
adapter.finish_requests(
|
||||
[aborted.request_id],
|
||||
RequestStatus.FINISHED_ABORTED,
|
||||
{aborted.request_id: aborted},
|
||||
)
|
||||
adapter.process_pending_chunks(waiting_queue, running_queue)
|
||||
|
||||
assert aborted.request_id not in adapter._active_streams
|
||||
assert [request.request_id for request in adapter._held_non_active] == []
|
||||
assert list(adapter._active_streams) == [waiting.request_id]
|
||||
assert waiting.status == RequestStatus.WAITING_FOR_CHUNK
|
||||
|
||||
|
||||
def test_restore_queues_skips_requests_missing_from_scheduler_requests(build_adapter):
|
||||
adapter, _ = build_adapter(stage_id=1)
|
||||
zombie = _req("req-zombie", RequestStatus.WAITING_FOR_CHUNK)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,388 @@
|
||||
"""Run two MiniCPM-o Realtime duplex demo processes concurrently.
|
||||
|
||||
This E2E driver covers the same process boundary users exercise manually:
|
||||
two independent ``realtime_duplex_demo.py`` invocations, two distinct input
|
||||
WAVs, and two distinct output directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
DEMO_PATH = REPO_ROOT / "examples/online_serving/minicpmo/realtime_duplex_demo.py"
|
||||
AUDIO_DELTA_EVENTS = {"response.audio.delta", "response.output_audio.delta"}
|
||||
|
||||
|
||||
def _canonical_path(path: str) -> Path:
|
||||
return Path(path).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def _validate_pair_args(args: argparse.Namespace) -> dict[str, object]:
|
||||
input_a = _canonical_path(args.input_wav_a)
|
||||
input_b = _canonical_path(args.input_wav_b)
|
||||
output_a = _canonical_path(args.output_dir_a)
|
||||
output_b = _canonical_path(args.output_dir_b)
|
||||
if output_a == output_b:
|
||||
raise ValueError("output directories must be different")
|
||||
if not input_a.is_file():
|
||||
raise ValueError(f"input WAV does not exist: {input_a}")
|
||||
if not input_b.is_file():
|
||||
raise ValueError(f"input WAV does not exist: {input_b}")
|
||||
sha_a = _sha256_file(input_a)
|
||||
sha_b = _sha256_file(input_b)
|
||||
if sha_a == sha_b:
|
||||
raise ValueError("input WAV files must have different content")
|
||||
return {
|
||||
"input_wav_a": str(input_a),
|
||||
"input_wav_b": str(input_b),
|
||||
"input_sha256_a": sha_a,
|
||||
"input_sha256_b": sha_b,
|
||||
"output_dir_a": str(output_a),
|
||||
"output_dir_b": str(output_b),
|
||||
}
|
||||
|
||||
|
||||
def _event_response_id(event: dict[str, object]) -> str | None:
|
||||
response_id = event.get("response_id")
|
||||
if isinstance(response_id, str):
|
||||
return response_id
|
||||
response = event.get("response")
|
||||
if isinstance(response, dict) and isinstance(response.get("id"), str):
|
||||
return response["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _read_events(path: Path) -> list[dict[str, object]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
events: list[dict[str, object]] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
event = json.loads(line)
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
def _response_ids_from_artifacts(result: dict[str, object], events: list[dict[str, object]]) -> list[str]:
|
||||
response_ids = result.get("response_ids")
|
||||
if isinstance(response_ids, list):
|
||||
ids = [item for item in response_ids if isinstance(item, str)]
|
||||
if ids:
|
||||
return ids
|
||||
seen: list[str] = []
|
||||
for event in events:
|
||||
response_id = _event_response_id(event)
|
||||
if response_id is not None and response_id not in seen:
|
||||
seen.append(response_id)
|
||||
return seen
|
||||
|
||||
|
||||
def _audio_delta_duration_ms(event: dict[str, object]) -> float | None:
|
||||
encoded = event.get("delta") or event.get("audio")
|
||||
if not isinstance(encoded, str) or not encoded:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(encoded)
|
||||
except ValueError:
|
||||
return None
|
||||
fmt = str(event.get("format") or event.get("audio_format") or "pcm16").lower()
|
||||
sample_rate = event.get("sample_rate_hz") or event.get("sample_rate") or 24_000
|
||||
if not isinstance(sample_rate, int | float) or sample_rate <= 0:
|
||||
return None
|
||||
if "wav" in fmt:
|
||||
try:
|
||||
with wave.open(io.BytesIO(raw), "rb") as wav:
|
||||
return round((wav.getnframes() * 1000.0) / wav.getframerate(), 3)
|
||||
except (EOFError, wave.Error):
|
||||
return None
|
||||
bytes_per_sample = 4 if "f32" in fmt else 2
|
||||
return round((len(raw) * 1000.0) / (bytes_per_sample * float(sample_rate)), 3)
|
||||
|
||||
|
||||
def _audio_delta_received_at_s(event: dict[str, object]) -> float | None:
|
||||
raw = event.get("_client_received_at_s")
|
||||
if isinstance(raw, int | float):
|
||||
return float(raw)
|
||||
return None
|
||||
|
||||
|
||||
def _session_audio_contract_ok(
|
||||
*,
|
||||
audio_delta_count: int,
|
||||
delta_response_ids: list[str],
|
||||
response_ids: list[str],
|
||||
first_audio_delta_before_done: bool,
|
||||
) -> bool:
|
||||
if audio_delta_count <= 0 or not response_ids:
|
||||
return False
|
||||
if len(delta_response_ids) != audio_delta_count:
|
||||
return False
|
||||
delta_id_set = set(delta_response_ids)
|
||||
if len(delta_id_set) != 1:
|
||||
return False
|
||||
return first_audio_delta_before_done and next(iter(delta_id_set)) in set(response_ids)
|
||||
|
||||
|
||||
def _summarize_session(
|
||||
*,
|
||||
label: str,
|
||||
input_wav: str,
|
||||
output_dir: str,
|
||||
returncode: int,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
) -> dict[str, object]:
|
||||
output_path = Path(output_dir)
|
||||
result = _read_json(output_path / "result.json")
|
||||
events = _read_events(output_path / "events.jsonl")
|
||||
audio_delta_indices = [index for index, event in enumerate(events) if event.get("type") in AUDIO_DELTA_EVENTS]
|
||||
done_indices = [index for index, event in enumerate(events) if event.get("type") == "response.done"]
|
||||
response_ids = _response_ids_from_artifacts(result, events)
|
||||
delta_response_ids = [
|
||||
response_id
|
||||
for response_id in (_event_response_id(events[index]) for index in audio_delta_indices)
|
||||
if response_id is not None
|
||||
]
|
||||
first_audio_delta_before_done = bool(audio_delta_indices and done_indices) and min(audio_delta_indices) < min(
|
||||
done_indices
|
||||
)
|
||||
audio_delta_events = [events[index] for index in audio_delta_indices]
|
||||
first_received_at_s = next(
|
||||
(
|
||||
received_at
|
||||
for received_at in (_audio_delta_received_at_s(event) for event in audio_delta_events)
|
||||
if received_at is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
audio_delta_timings = []
|
||||
for event in audio_delta_events:
|
||||
received_at_s = _audio_delta_received_at_s(event)
|
||||
audio_delta_timings.append(
|
||||
{
|
||||
"response_id": _event_response_id(event),
|
||||
"received_at_s": received_at_s,
|
||||
"arrival_offset_ms": (
|
||||
round((received_at_s - first_received_at_s) * 1000.0, 3)
|
||||
if received_at_s is not None and first_received_at_s is not None
|
||||
else None
|
||||
),
|
||||
"audio_duration_ms": _audio_delta_duration_ms(event),
|
||||
}
|
||||
)
|
||||
all_audio_deltas_same_response = (
|
||||
len(delta_response_ids) == len(audio_delta_indices) and len(set(delta_response_ids)) == 1
|
||||
)
|
||||
response_id_nonempty = bool(response_ids)
|
||||
response_audio_contract_ok = _session_audio_contract_ok(
|
||||
audio_delta_count=len(audio_delta_indices),
|
||||
delta_response_ids=delta_response_ids,
|
||||
response_ids=response_ids,
|
||||
first_audio_delta_before_done=first_audio_delta_before_done,
|
||||
)
|
||||
return {
|
||||
"label": label,
|
||||
"input_wav": input_wav,
|
||||
"output_dir": output_dir,
|
||||
"returncode": returncode,
|
||||
"ok": returncode == 0 and result.get("ok") is True and response_audio_contract_ok,
|
||||
"result_ok": result.get("ok"),
|
||||
"response_ids": response_ids,
|
||||
"response_id_nonempty": response_id_nonempty,
|
||||
"audio_bytes": result.get("audio_bytes", 0),
|
||||
"audio_delta_count": len(audio_delta_indices),
|
||||
"audio_delta_response_ids": delta_response_ids,
|
||||
"audio_delta_timings": audio_delta_timings,
|
||||
"all_audio_deltas_same_response": all_audio_deltas_same_response,
|
||||
"first_audio_delta_before_done": first_audio_delta_before_done,
|
||||
"response_audio_contract_ok": response_audio_contract_ok,
|
||||
"event_count": len(events),
|
||||
"stdout_tail": stdout[-4000:],
|
||||
"stderr_tail": stderr[-4000:],
|
||||
}
|
||||
|
||||
|
||||
def _identity_isolation_ok(sessions: list[dict[str, object]]) -> bool:
|
||||
seen: set[str] = set()
|
||||
for session in sessions:
|
||||
ids = {response_id for response_id in session.get("response_ids", []) if isinstance(response_id, str)}
|
||||
if not ids:
|
||||
return False
|
||||
if ids & seen:
|
||||
return False
|
||||
seen.update(ids)
|
||||
return True
|
||||
|
||||
|
||||
async def _run_demo_process(
|
||||
*,
|
||||
label: str,
|
||||
args: argparse.Namespace,
|
||||
input_wav: str,
|
||||
output_dir: str,
|
||||
) -> dict[str, object]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(DEMO_PATH),
|
||||
"--url",
|
||||
args.url,
|
||||
"--model",
|
||||
args.model,
|
||||
"--input-wav",
|
||||
input_wav,
|
||||
"--output-dir",
|
||||
output_dir,
|
||||
"--chunk-ms",
|
||||
str(args.chunk_ms),
|
||||
"--timeout-s",
|
||||
str(args.timeout_s),
|
||||
"--session-id",
|
||||
f"duplex-pair-{label}-{uuid.uuid4().hex}",
|
||||
]
|
||||
if args.no_realtime_pacing:
|
||||
command.append("--no-realtime-pacing")
|
||||
if args.require_audio:
|
||||
command.append("--require-audio")
|
||||
command.extend(["--ref-audio", str(args.ref_audio)])
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
cwd=str(REPO_ROOT),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=args.timeout_s + 15.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
stdout_bytes, stderr_bytes = await process.communicate()
|
||||
return _summarize_session(
|
||||
label=label,
|
||||
input_wav=input_wav,
|
||||
output_dir=output_dir,
|
||||
returncode=-1,
|
||||
stdout=stdout_bytes.decode(errors="replace"),
|
||||
stderr=(stderr_bytes.decode(errors="replace") + "\nclient timeout"),
|
||||
)
|
||||
return _summarize_session(
|
||||
label=label,
|
||||
input_wav=input_wav,
|
||||
output_dir=output_dir,
|
||||
returncode=process.returncode if process.returncode is not None else -1,
|
||||
stdout=stdout_bytes.decode(errors="replace"),
|
||||
stderr=stderr_bytes.decode(errors="replace"),
|
||||
)
|
||||
|
||||
|
||||
async def run_pair(args: argparse.Namespace) -> dict[str, object]:
|
||||
validated = _validate_pair_args(args)
|
||||
Path(validated["output_dir_a"]).mkdir(parents=True, exist_ok=True)
|
||||
Path(validated["output_dir_b"]).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sessions = await asyncio.gather(
|
||||
_run_demo_process(
|
||||
label="a",
|
||||
args=args,
|
||||
input_wav=str(validated["input_wav_a"]),
|
||||
output_dir=str(validated["output_dir_a"]),
|
||||
),
|
||||
_run_demo_process(
|
||||
label="b",
|
||||
args=args,
|
||||
input_wav=str(validated["input_wav_b"]),
|
||||
output_dir=str(validated["output_dir_b"]),
|
||||
),
|
||||
)
|
||||
identity_isolation_ok = _identity_isolation_ok(sessions)
|
||||
min_audio_delta_ok = all(
|
||||
int(session.get("audio_delta_count", 0)) >= args.min_audio_deltas_per_session for session in sessions
|
||||
)
|
||||
response_audio_contract_ok = all(session.get("response_audio_contract_ok") is True for session in sessions)
|
||||
result = {
|
||||
"ok": (
|
||||
all(session.get("ok") is True for session in sessions)
|
||||
and identity_isolation_ok
|
||||
and min_audio_delta_ok
|
||||
and response_audio_contract_ok
|
||||
),
|
||||
"input_wavs_distinct": True,
|
||||
"output_dirs_distinct": True,
|
||||
"identity_isolation_ok": identity_isolation_ok,
|
||||
"min_audio_deltas_per_session": args.min_audio_deltas_per_session,
|
||||
"min_audio_delta_ok": min_audio_delta_ok,
|
||||
"response_audio_contract_ok": response_audio_contract_ok,
|
||||
"inputs": {
|
||||
"a": validated["input_wav_a"],
|
||||
"b": validated["input_wav_b"],
|
||||
"sha256_a": validated["input_sha256_a"],
|
||||
"sha256_b": validated["input_sha256_b"],
|
||||
},
|
||||
"sessions": sessions,
|
||||
}
|
||||
summary_output = (
|
||||
Path(args.summary_output) if args.summary_output else Path(validated["output_dir_a"]).parent / "summary.json"
|
||||
)
|
||||
summary_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
summary_output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return result
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1")
|
||||
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
||||
parser.add_argument("--input-wav-a", required=True)
|
||||
parser.add_argument("--input-wav-b", required=True)
|
||||
parser.add_argument("--ref-audio", required=True)
|
||||
parser.add_argument("--output-dir-a", required=True)
|
||||
parser.add_argument("--output-dir-b", required=True)
|
||||
parser.add_argument("--summary-output")
|
||||
parser.add_argument("--chunk-ms", type=int, default=200)
|
||||
parser.add_argument("--timeout-s", type=float, default=120.0)
|
||||
parser.add_argument("--no-realtime-pacing", action="store_true")
|
||||
parser.add_argument("--require-audio", action="store_true")
|
||||
parser.add_argument("--min-audio-deltas-per-session", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
if args.min_audio_deltas_per_session < 0:
|
||||
parser.error("--min-audio-deltas-per-session must be non-negative")
|
||||
return args
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = asyncio.run(run_pair(parse_args()))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,556 @@
|
||||
"""Concurrent MiniCPM-o Realtime duplex and resumable-session E2E driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from minicpmo_realtime_duplex_scenarios import _url_with_model, run_demo # noqa: E402
|
||||
|
||||
|
||||
def _with_resume_mode(url: str) -> str:
|
||||
parts = urlsplit(url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["resume"] = "1"
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
def _response_ids(result: dict[str, object]) -> set[str]:
|
||||
values = result.get("completed_response_ids")
|
||||
return {value for value in values if isinstance(value, str)} if isinstance(values, list) else set()
|
||||
|
||||
|
||||
def _event_session_id(event: dict[str, object]) -> str | None:
|
||||
session_id = event.get("session_id")
|
||||
if isinstance(session_id, str):
|
||||
return session_id
|
||||
inner = event.get("event")
|
||||
if isinstance(inner, dict):
|
||||
nested_session_id = inner.get("session_id")
|
||||
if isinstance(nested_session_id, str):
|
||||
return nested_session_id
|
||||
return None
|
||||
|
||||
|
||||
def _error_code(event: dict[str, object]) -> str | None:
|
||||
error = event.get("error")
|
||||
if isinstance(error, dict) and isinstance(error.get("code"), str):
|
||||
return error["code"]
|
||||
code = event.get("code")
|
||||
return code if isinstance(code, str) else None
|
||||
|
||||
|
||||
def _validate_identity_isolation(results: list[dict[str, object]]) -> bool:
|
||||
seen: set[str] = set()
|
||||
for result in results:
|
||||
current = _response_ids(result)
|
||||
if current & seen:
|
||||
return False
|
||||
seen.update(current)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_semantic_isolation(
|
||||
results: list[dict[str, object]],
|
||||
*,
|
||||
input_wavs: list[str],
|
||||
expected_tokens: list[str],
|
||||
) -> bool:
|
||||
if not input_wavs:
|
||||
return True
|
||||
if len(results) != len(input_wavs):
|
||||
return False
|
||||
input_hashes = [hashlib.sha256(Path(path).read_bytes()).digest() for path in input_wavs]
|
||||
if len(set(input_hashes)) != len(input_hashes):
|
||||
return False
|
||||
if not expected_tokens:
|
||||
return True
|
||||
if len(expected_tokens) != len(results):
|
||||
return False
|
||||
for result, expected_token in zip(results, expected_tokens, strict=True):
|
||||
details = result.get("transcript_integrity")
|
||||
transcripts = (
|
||||
[str(item.get("transcript", "")) for item in details if isinstance(item, dict)]
|
||||
if isinstance(details, list)
|
||||
else []
|
||||
)
|
||||
if expected_token not in "".join(transcripts):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _receive_until(ws, event_type: str, *, timeout_s: float) -> tuple[dict[str, object], list[dict[str, object]]]:
|
||||
async def receive() -> tuple[dict[str, object], list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
event = json.loads(raw)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
events.append(event)
|
||||
if event.get("type") == event_type:
|
||||
return event, events
|
||||
|
||||
return await asyncio.wait_for(receive(), timeout=timeout_s)
|
||||
|
||||
|
||||
async def _open_admission_session(
|
||||
args: argparse.Namespace,
|
||||
session_id: str,
|
||||
) -> tuple[object, dict[str, object]]:
|
||||
url = _url_with_model(args.url, args.model, session_id=session_id)
|
||||
ws = await websockets.connect(url, max_size=64 * 1024 * 1024)
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"minicpmo45_native_duplex": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
created, _ = await _receive_until(ws, "session.created", timeout_s=args.timeout_s)
|
||||
return ws, created
|
||||
|
||||
|
||||
async def _close_admission_session(ws, *, timeout_s: float) -> None:
|
||||
await ws.send(json.dumps({"type": "session.close"}))
|
||||
await _receive_until(ws, "session.closed", timeout_s=timeout_s)
|
||||
await ws.close()
|
||||
|
||||
|
||||
async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str, object]:
|
||||
if limit < 1:
|
||||
raise ValueError("admission limit must be positive")
|
||||
prefix = f"admission-{uuid.uuid4().hex}"
|
||||
accepted: list[tuple[object, dict[str, object]]] = []
|
||||
replacement: tuple[object, dict[str, object]] | None = None
|
||||
overflow_code = None
|
||||
try:
|
||||
for index in range(limit):
|
||||
accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}"))
|
||||
|
||||
overflow_id = f"{prefix}-overflow"
|
||||
overflow_url = _url_with_model(args.url, args.model, session_id=overflow_id)
|
||||
async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow:
|
||||
await overflow.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": overflow_id,
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"minicpmo45_native_duplex": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
error, _ = await _receive_until(overflow, "error", timeout_s=args.timeout_s)
|
||||
overflow_code = _error_code(error)
|
||||
|
||||
first_ws, _ = accepted.pop(0)
|
||||
await _close_admission_session(first_ws, timeout_s=args.timeout_s)
|
||||
replacement = await _open_admission_session(args, f"{prefix}-replacement")
|
||||
|
||||
first_capabilities = accepted[0][1].get("session") if accepted else replacement[1].get("session")
|
||||
capabilities = first_capabilities.get("capabilities") if isinstance(first_capabilities, dict) else None
|
||||
advertised_multi = capabilities.get("supports_multi_session") if isinstance(capabilities, dict) else None
|
||||
admission_mode = capabilities.get("session_admission_mode") if isinstance(capabilities, dict) else None
|
||||
return {
|
||||
"ok": (
|
||||
overflow_code == "resource_exhausted"
|
||||
and replacement[1].get("type") == "session.created"
|
||||
and admission_mode == "engine_managed"
|
||||
),
|
||||
"configured_limit": limit,
|
||||
"accepted_before_rejection": limit,
|
||||
"overflow_error_code": overflow_code,
|
||||
"replacement_accepted": True,
|
||||
"advertised_multi_session": advertised_multi,
|
||||
"session_admission_mode": admission_mode,
|
||||
}
|
||||
finally:
|
||||
cleanup = list(accepted)
|
||||
if replacement is not None:
|
||||
cleanup.append(replacement)
|
||||
for ws, _ in cleanup:
|
||||
try:
|
||||
await _close_admission_session(ws, timeout_s=args.timeout_s)
|
||||
except Exception:
|
||||
await ws.close()
|
||||
|
||||
|
||||
async def _resume_probe(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
session_id: str,
|
||||
expect_expired: bool = False,
|
||||
) -> dict[str, object]:
|
||||
url = _url_with_model(args.url, args.model, session_id=session_id)
|
||||
async with websockets.connect(url, max_size=64 * 1024 * 1024) as first:
|
||||
await first.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"minicpmo45_native_duplex": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
created, first_events = await _receive_until(first, "session.created", timeout_s=args.timeout_s)
|
||||
token = created.get("resume_token")
|
||||
incarnation = created.get("incarnation")
|
||||
generation = created.get("attachment_generation")
|
||||
if not isinstance(token, str) or not isinstance(incarnation, int):
|
||||
raise RuntimeError("session.created omitted resumable credentials")
|
||||
last_seq = max(
|
||||
(
|
||||
event.get("server_event_seq", 0)
|
||||
for event in first_events
|
||||
if isinstance(event.get("server_event_seq"), int)
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
|
||||
delay_s = args.expire_after_s if expect_expired else args.resume_after_ms / 1000
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
resume_url = _with_resume_mode(url)
|
||||
async with websockets.connect(resume_url, max_size=64 * 1024 * 1024) as second:
|
||||
await second.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.resume",
|
||||
"session_id": session_id,
|
||||
"incarnation": incarnation,
|
||||
"resume_token": token,
|
||||
"last_received_server_event_seq": last_seq,
|
||||
}
|
||||
)
|
||||
)
|
||||
if expect_expired:
|
||||
error, error_events = await _receive_until(second, "error", timeout_s=args.timeout_s)
|
||||
error_payload = error.get("error")
|
||||
code = error_payload.get("code") if isinstance(error_payload, dict) else error.get("code")
|
||||
return {
|
||||
"ok": code == "session_resume_expired",
|
||||
"session_id": session_id,
|
||||
"expired": True,
|
||||
"error_code": code,
|
||||
"event_count": len(error_events),
|
||||
}
|
||||
resumed, replay = await _receive_until(second, "session.resumed", timeout_s=args.timeout_s)
|
||||
rotated = resumed.get("resume_token")
|
||||
if not isinstance(rotated, str) or rotated == token:
|
||||
raise RuntimeError("session.resume did not rotate the resume token")
|
||||
await second.send(json.dumps({"type": "session.heartbeat"}))
|
||||
heartbeat, heartbeat_events = await _receive_until(
|
||||
second,
|
||||
"session.heartbeat_ack",
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
await second.send(json.dumps({"type": "session.close"}))
|
||||
closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s)
|
||||
|
||||
replay_sequences = [event["server_event_seq"] for event in replay if isinstance(event.get("server_event_seq"), int)]
|
||||
return {
|
||||
"ok": (
|
||||
resumed.get("session_id") == session_id
|
||||
and isinstance(generation, int)
|
||||
and resumed.get("attachment_generation") == generation + 1
|
||||
and _event_session_id(heartbeat) == session_id
|
||||
and _event_session_id(closed) == session_id
|
||||
and replay_sequences == sorted(replay_sequences)
|
||||
),
|
||||
"session_id": session_id,
|
||||
"resumed_session_id": resumed.get("session_id"),
|
||||
"heartbeat_session_id": _event_session_id(heartbeat),
|
||||
"closed_session_id": _event_session_id(closed),
|
||||
"initial_attachment_generation": generation,
|
||||
"resumed_attachment_generation": resumed.get("attachment_generation"),
|
||||
"replayed_event_count": len(replay_sequences),
|
||||
"replayed_event_sequences": replay_sequences,
|
||||
"heartbeat_event_count": len(heartbeat_events),
|
||||
"close_event_count": len(close_events),
|
||||
"token_rotated": True,
|
||||
}
|
||||
|
||||
|
||||
async def _takeover_probe(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> dict[str, object]:
|
||||
url = _url_with_model(args.url, args.model, session_id=session_id)
|
||||
first = await websockets.connect(url, max_size=64 * 1024 * 1024)
|
||||
second = None
|
||||
try:
|
||||
await first.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"minicpmo45_native_duplex": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
created, first_events = await _receive_until(first, "session.created", timeout_s=args.timeout_s)
|
||||
token = created.get("resume_token")
|
||||
incarnation = created.get("incarnation")
|
||||
generation = created.get("attachment_generation")
|
||||
if not isinstance(token, str) or not isinstance(incarnation, int) or not isinstance(generation, int):
|
||||
raise RuntimeError("session.created omitted takeover credentials")
|
||||
last_seq = max(
|
||||
(
|
||||
event.get("server_event_seq", 0)
|
||||
for event in first_events
|
||||
if isinstance(event.get("server_event_seq"), int)
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
|
||||
second = await websockets.connect(_with_resume_mode(url), max_size=64 * 1024 * 1024)
|
||||
await second.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.resume",
|
||||
"session_id": session_id,
|
||||
"incarnation": incarnation,
|
||||
"resume_token": token,
|
||||
"last_received_server_event_seq": last_seq,
|
||||
}
|
||||
)
|
||||
)
|
||||
resumed, replay_events = await _receive_until(second, "session.resumed", timeout_s=args.timeout_s)
|
||||
replaced, replaced_events = await _receive_until(first, "session.replaced", timeout_s=args.timeout_s)
|
||||
await asyncio.wait_for(first.wait_closed(), timeout=args.timeout_s)
|
||||
|
||||
rejected_old_writes = 0
|
||||
for _ in range(4):
|
||||
try:
|
||||
await first.send(json.dumps({"type": "session.heartbeat"}))
|
||||
except ConnectionClosed:
|
||||
rejected_old_writes += 1
|
||||
|
||||
await second.send(json.dumps({"type": "session.heartbeat"}))
|
||||
heartbeat, heartbeat_events = await _receive_until(
|
||||
second,
|
||||
"session.heartbeat_ack",
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
await second.send(json.dumps({"type": "session.close"}))
|
||||
closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s)
|
||||
rotated_token = resumed.get("resume_token")
|
||||
return {
|
||||
"ok": (
|
||||
resumed.get("session_id") == session_id
|
||||
and resumed.get("attachment_generation") == generation + 1
|
||||
and isinstance(rotated_token, str)
|
||||
and rotated_token != token
|
||||
and _event_session_id(replaced) == session_id
|
||||
and replaced.get("attachment_generation") == generation
|
||||
and rejected_old_writes == 4
|
||||
and _event_session_id(heartbeat) == session_id
|
||||
and _event_session_id(closed) == session_id
|
||||
),
|
||||
"session_id": session_id,
|
||||
"initial_attachment_generation": generation,
|
||||
"resumed_attachment_generation": resumed.get("attachment_generation"),
|
||||
"replaced_attachment_generation": replaced.get("attachment_generation"),
|
||||
"token_rotated": isinstance(rotated_token, str) and rotated_token != token,
|
||||
"old_attachment_closed": True,
|
||||
"rejected_old_writes": rejected_old_writes,
|
||||
"replay_event_count": len(replay_events),
|
||||
"replaced_event_count": len(replaced_events),
|
||||
"heartbeat_event_count": len(heartbeat_events),
|
||||
"close_event_count": len(close_events),
|
||||
}
|
||||
finally:
|
||||
if second is not None:
|
||||
await second.close()
|
||||
await first.close()
|
||||
|
||||
|
||||
def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
|
||||
validation_mode = "response-required" if args.response_required else "model-policy"
|
||||
input_wav = args.session_input_wav[index] if args.session_input_wav else args.input_wav
|
||||
return SimpleNamespace(
|
||||
url=args.url,
|
||||
model=args.model,
|
||||
session_id=f"multi-{index}-{uuid.uuid4().hex}",
|
||||
input_wav=input_wav,
|
||||
ref_audio=args.ref_audio,
|
||||
turn_input_wav=list(args.turn_input_wav),
|
||||
output_dir=str(Path(args.output_dir) / f"session_{index:02d}"),
|
||||
output_audio_format="pcm16",
|
||||
chunk_ms=args.chunk_ms,
|
||||
realtime_input=args.realtime_input,
|
||||
first_turn_ms=args.first_turn_ms,
|
||||
turn_duration_ms=list(args.turn_duration_ms),
|
||||
first_turn_transcript=f"session {index} input",
|
||||
omit_transcript_hints=True,
|
||||
validation_mode=validation_mode,
|
||||
temperature=args.temperature,
|
||||
scenario="sequential",
|
||||
require_audio=args.response_required,
|
||||
require_distinct_inputs=False,
|
||||
expect_empty_turn=[],
|
||||
short_ack_ms=350,
|
||||
turns=args.turns,
|
||||
timeout_s=args.timeout_s,
|
||||
model_policy_settle_ms=args.model_policy_settle_ms,
|
||||
)
|
||||
|
||||
|
||||
async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
|
||||
if args.sessions < 1:
|
||||
raise ValueError("--sessions must be positive")
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
resume_result = None
|
||||
if args.disconnect_session_index is not None:
|
||||
if not 0 <= args.disconnect_session_index < args.sessions:
|
||||
raise ValueError("--disconnect-session-index is outside the session range")
|
||||
resume_result = await _resume_probe(
|
||||
args,
|
||||
session_id=f"resume-{args.disconnect_session_index}-{uuid.uuid4().hex}",
|
||||
)
|
||||
takeover_result = None
|
||||
if args.takeover_session_index is not None:
|
||||
if not 0 <= args.takeover_session_index < args.sessions:
|
||||
raise ValueError("--takeover-session-index is outside the session range")
|
||||
takeover_result = await _takeover_probe(
|
||||
args,
|
||||
session_id=f"takeover-{args.takeover_session_index}-{uuid.uuid4().hex}",
|
||||
)
|
||||
expiry_result = None
|
||||
if args.expire_session_index is not None:
|
||||
if not 0 <= args.expire_session_index < args.sessions:
|
||||
raise ValueError("--expire-session-index is outside the session range")
|
||||
expiry_result = await _resume_probe(
|
||||
args,
|
||||
session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}",
|
||||
expect_expired=True,
|
||||
)
|
||||
admission_result = (
|
||||
await _admission_probe(args, limit=args.verify_admission_limit)
|
||||
if args.verify_admission_limit is not None
|
||||
else None
|
||||
)
|
||||
|
||||
session_results = await asyncio.gather(
|
||||
*(run_demo(_demo_args(args, index)) for index in range(args.sessions)),
|
||||
return_exceptions=True,
|
||||
)
|
||||
failures = [repr(result) for result in session_results if isinstance(result, BaseException)]
|
||||
completed = [result for result in session_results if isinstance(result, dict)]
|
||||
identity_isolation_ok = _validate_identity_isolation(completed)
|
||||
semantic_isolation_ok = _validate_semantic_isolation(
|
||||
completed,
|
||||
input_wavs=list(args.session_input_wav),
|
||||
expected_tokens=list(args.session_expected_token),
|
||||
)
|
||||
result = {
|
||||
"ok": (
|
||||
not failures
|
||||
and len(completed) == args.sessions
|
||||
and all(item.get("ok") is True for item in completed)
|
||||
and identity_isolation_ok
|
||||
and semantic_isolation_ok
|
||||
and (resume_result is None or resume_result.get("ok") is True)
|
||||
and (takeover_result is None or takeover_result.get("ok") is True)
|
||||
and (expiry_result is None or expiry_result.get("ok") is True)
|
||||
and (admission_result is None or admission_result.get("ok") is True)
|
||||
),
|
||||
"session_count": args.sessions,
|
||||
"identity_isolation_ok": identity_isolation_ok,
|
||||
"semantic_isolation_ok": semantic_isolation_ok,
|
||||
"resume": resume_result,
|
||||
"takeover": takeover_result,
|
||||
"expiry": expiry_result,
|
||||
"admission": admission_result,
|
||||
"failures": failures,
|
||||
"sessions": completed,
|
||||
}
|
||||
(output_dir / "summary.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="ws://127.0.0.1:8113/v1/realtime?duplex=1")
|
||||
parser.add_argument("--base-url", help="Deprecated alias; /v1/realtime is appended when supplied.")
|
||||
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
||||
parser.add_argument("--sessions", type=int, default=2)
|
||||
parser.add_argument("--input-wav", required=True)
|
||||
parser.add_argument("--ref-audio", help="Optional WAV used as the MiniCPM-o voice prompt for every session.")
|
||||
parser.add_argument("--session-input-wav", action="append", default=[])
|
||||
parser.add_argument("--session-expected-token", action="append", default=[])
|
||||
parser.add_argument("--turn-input-wav", action="append", default=[])
|
||||
parser.add_argument("--output-dir", default="/tmp/minicpmo_pr3907_multi_session_e2e")
|
||||
parser.add_argument("--realtime-input", action="store_true")
|
||||
parser.add_argument("--chunk-ms", type=int, default=200)
|
||||
parser.add_argument("--turns", type=int, default=1)
|
||||
parser.add_argument("--first-turn-ms", type=int, default=1400)
|
||||
parser.add_argument("--turn-duration-ms", type=int, action="append", default=[])
|
||||
parser.add_argument("--response-required", action="store_true")
|
||||
parser.add_argument("--temperature", type=float, default=None)
|
||||
parser.add_argument("--disconnect-session-index", type=int)
|
||||
parser.add_argument("--takeover-session-index", type=int)
|
||||
parser.add_argument("--resume-after-ms", type=int, default=1000)
|
||||
parser.add_argument("--expire-session-index", type=int)
|
||||
parser.add_argument("--expire-after-s", type=float, default=6.0)
|
||||
parser.add_argument("--verify-admission-limit", type=int)
|
||||
parser.add_argument("--model-policy-settle-ms", type=int, default=600)
|
||||
parser.add_argument("--timeout-s", type=float, default=120.0)
|
||||
args = parser.parse_args()
|
||||
if args.base_url:
|
||||
args.url = args.base_url.rstrip("/") + "/v1/realtime?duplex=1"
|
||||
if args.session_input_wav and len(args.session_input_wav) != args.sessions:
|
||||
parser.error("provide exactly one --session-input-wav per session")
|
||||
if args.session_expected_token and len(args.session_expected_token) != args.sessions:
|
||||
parser.error("provide exactly one --session-expected-token per session")
|
||||
if args.session_expected_token and not args.session_input_wav:
|
||||
parser.error("--session-expected-token requires --session-input-wav")
|
||||
return args
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = asyncio.run(run_multi_session(parse_args()))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,467 @@
|
||||
"""Validate MiniCPM-o Realtime duplex soft-interrupt delta streaming.
|
||||
|
||||
This E2E driver runs the public ``realtime_duplex_demo.py`` against a live
|
||||
duplex backend. Arbitrary audio defaults to the model-policy lifecycle contract.
|
||||
The stronger response-required mode binds the two-response contract to a known
|
||||
input checksum and expected second response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
DEMO_PATH = REPO_ROOT / "examples/online_serving/minicpmo/realtime_duplex_demo.py"
|
||||
AUDIO_DELTA_EVENTS = {"response.audio.delta", "response.output_audio.delta"}
|
||||
TRANSCRIPT_DELTA_EVENTS = {
|
||||
"response.audio_transcript.delta",
|
||||
"response.output_text.delta",
|
||||
}
|
||||
|
||||
|
||||
def _canonical_path(path: str) -> Path:
|
||||
return Path(path).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
def _validate_input_sha256(path: Path, expected: str) -> str:
|
||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if actual != expected.lower():
|
||||
raise ValueError(f"input WAV SHA256 mismatch: expected {expected.lower()}, got {actual}")
|
||||
return actual
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _read_events(path: Path) -> list[dict[str, object]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
events: list[dict[str, object]] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
event = json.loads(line)
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
def _event_response_id(event: dict[str, object]) -> str | None:
|
||||
response_id = event.get("response_id")
|
||||
if isinstance(response_id, str):
|
||||
return response_id
|
||||
response = event.get("response")
|
||||
if isinstance(response, dict) and isinstance(response.get("id"), str):
|
||||
return response["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _event_received_at_s(event: dict[str, object]) -> float | None:
|
||||
received_at = event.get("_client_received_at_s")
|
||||
if isinstance(received_at, int | float):
|
||||
return float(received_at)
|
||||
return None
|
||||
|
||||
|
||||
def _first_event_index(events: list[dict[str, object]], event_type: str) -> int | None:
|
||||
for index, event in enumerate(events):
|
||||
if event.get("type") == event_type:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _event_counts(events: list[dict[str, object]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for event in events:
|
||||
event_type = str(event.get("type"))
|
||||
counts[event_type] = counts.get(event_type, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _response_created_ids(events: list[dict[str, object]]) -> list[str]:
|
||||
response_ids: list[str] = []
|
||||
for event in events:
|
||||
if event.get("type") != "response.created":
|
||||
continue
|
||||
response_id = _event_response_id(event)
|
||||
if response_id and response_id not in response_ids:
|
||||
response_ids.append(response_id)
|
||||
return response_ids
|
||||
|
||||
|
||||
def _audio_delta_duration_ms(event: dict[str, object]) -> float | None:
|
||||
encoded = event.get("delta") or event.get("audio")
|
||||
if not isinstance(encoded, str) or not encoded:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(encoded)
|
||||
except ValueError:
|
||||
return None
|
||||
fmt = str(event.get("format") or event.get("audio_format") or "pcm16").lower()
|
||||
sample_rate = event.get("sample_rate_hz") or event.get("sample_rate") or 24_000
|
||||
if not isinstance(sample_rate, int | float) or sample_rate <= 0:
|
||||
return None
|
||||
if "wav" in fmt:
|
||||
try:
|
||||
with wave.open(io.BytesIO(raw), "rb") as wav_file:
|
||||
return round((wav_file.getnframes() * 1000.0) / wav_file.getframerate(), 3)
|
||||
except (EOFError, wave.Error):
|
||||
return None
|
||||
bytes_per_sample = 4 if "f32" in fmt else 2
|
||||
return round((len(raw) * 1000.0) / (bytes_per_sample * float(sample_rate)), 3)
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
return "".join(text.split())
|
||||
|
||||
|
||||
def _compact_sequence(events: list[dict[str, object]]) -> list[str]:
|
||||
sequence: list[str] = []
|
||||
for event in events:
|
||||
event_type = event.get("type")
|
||||
response_id = _event_response_id(event)
|
||||
suffix = f" {response_id[-8:]}" if response_id else ""
|
||||
if event_type == "response.listen":
|
||||
sequence.append("listen")
|
||||
elif event_type == "response.created":
|
||||
sequence.append(f"response.created{suffix}")
|
||||
elif event_type == "response.speak":
|
||||
sequence.append(f"speak{suffix}")
|
||||
elif event_type in AUDIO_DELTA_EVENTS:
|
||||
sequence.append(f"audio.delta{suffix}")
|
||||
elif event_type in TRANSCRIPT_DELTA_EVENTS:
|
||||
delta = str(event.get("delta") or "")
|
||||
sequence.append(f"text.delta{suffix}> {delta}")
|
||||
elif event_type == "response.done":
|
||||
sequence.append(f"response.done{suffix}")
|
||||
elif event_type == "input_audio_buffer.committed":
|
||||
sequence.append("input.committed")
|
||||
return sequence
|
||||
|
||||
|
||||
def _response_summary(
|
||||
events: list[dict[str, object]],
|
||||
response_id: str,
|
||||
*,
|
||||
t0: float | None,
|
||||
) -> dict[str, object]:
|
||||
created_indices = [
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event.get("type") == "response.created" and _event_response_id(event) == response_id
|
||||
]
|
||||
done_indices = [
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event.get("type") == "response.done" and _event_response_id(event) == response_id
|
||||
]
|
||||
audio_indices = [
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event.get("type") in AUDIO_DELTA_EVENTS and _event_response_id(event) == response_id
|
||||
]
|
||||
transcript_indices = [
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event.get("type") in TRANSCRIPT_DELTA_EVENTS and _event_response_id(event) == response_id
|
||||
]
|
||||
done_index = done_indices[0] if done_indices else None
|
||||
audio_before_done_ok = done_index is not None and all(index < done_index for index in audio_indices)
|
||||
stale_audio_count = sum(index > done_index for index in audio_indices) if done_index is not None else 0
|
||||
|
||||
def offset_ms(index: int) -> float | None:
|
||||
if t0 is None:
|
||||
return None
|
||||
received_at = _event_received_at_s(events[index])
|
||||
return round((received_at - t0) * 1000.0, 3) if received_at is not None else None
|
||||
|
||||
return {
|
||||
"response_id": response_id,
|
||||
"created_index": created_indices[0] if created_indices else None,
|
||||
"done_indices": done_indices,
|
||||
"created_offset_ms": offset_ms(created_indices[0]) if created_indices else None,
|
||||
"done_offset_ms": offset_ms(done_indices[0]) if done_indices else None,
|
||||
"audio_delta_count": len(audio_indices),
|
||||
"transcript_delta_count": len(transcript_indices),
|
||||
"audio_delta_offsets_ms": [offset_ms(index) for index in audio_indices],
|
||||
"audio_duration_ms": [_audio_delta_duration_ms(events[index]) for index in audio_indices],
|
||||
"transcript": "".join(str(events[index].get("delta") or "") for index in transcript_indices),
|
||||
"one_done": len(done_indices) == 1,
|
||||
"audio_before_done_ok": audio_before_done_ok,
|
||||
"stale_audio_count": stale_audio_count,
|
||||
}
|
||||
|
||||
|
||||
def summarize_artifacts(
|
||||
*,
|
||||
output_dir: Path,
|
||||
validation_mode: str,
|
||||
min_responses: int,
|
||||
min_audio_deltas_per_response: int,
|
||||
expect_second_response_substring: str | None,
|
||||
) -> dict[str, object]:
|
||||
if validation_mode not in {"model-policy", "response-required"}:
|
||||
raise ValueError(f"unsupported validation mode: {validation_mode}")
|
||||
result = _read_json(output_dir / "result.json")
|
||||
events = _read_events(output_dir / "events.jsonl")
|
||||
response_ids = _response_created_ids(events)
|
||||
times = [_event_received_at_s(event) for event in events]
|
||||
clean_times = [received_at for received_at in times if received_at is not None]
|
||||
t0 = min(clean_times) if clean_times else None
|
||||
response_summaries = [_response_summary(events, response_id, t0=t0) for response_id in response_ids]
|
||||
commit_index = _first_event_index(events, "input_audio_buffer.committed")
|
||||
first_created_index = next(
|
||||
(summary["created_index"] for summary in response_summaries if isinstance(summary.get("created_index"), int)),
|
||||
None,
|
||||
)
|
||||
first_done_index = (
|
||||
response_summaries[0]["done_indices"][0]
|
||||
if response_summaries and response_summaries[0]["done_indices"]
|
||||
else None
|
||||
)
|
||||
second_created_index = (
|
||||
response_summaries[1]["created_index"]
|
||||
if len(response_summaries) >= 2 and isinstance(response_summaries[1].get("created_index"), int)
|
||||
else None
|
||||
)
|
||||
last_done_index = next(
|
||||
(summary["done_indices"][0] for summary in reversed(response_summaries) if summary.get("done_indices")),
|
||||
None,
|
||||
)
|
||||
listen_indices = [index for index, event in enumerate(events) if event.get("type") == "response.listen"]
|
||||
effective_min_responses = min_responses if validation_mode == "response-required" else 1
|
||||
enough_responses = len(response_summaries) >= effective_min_responses
|
||||
response_lifecycle_ok = enough_responses and all(bool(summary.get("one_done")) for summary in response_summaries)
|
||||
multi_delta_ok = enough_responses and all(
|
||||
int(summary.get("audio_delta_count", 0)) >= min_audio_deltas_per_response for summary in response_summaries
|
||||
)
|
||||
response_audio_contract_ok = enough_responses and all(
|
||||
summary.get("audio_before_done_ok") is True and int(summary.get("stale_audio_count", 0)) == 0
|
||||
for summary in response_summaries
|
||||
)
|
||||
response_before_final_commit = (
|
||||
first_created_index is not None and commit_index is not None and first_created_index < commit_index
|
||||
)
|
||||
second_response_before_final_commit = (
|
||||
second_created_index is not None and commit_index is not None and second_created_index < commit_index
|
||||
)
|
||||
listen_before_first_response = first_created_index is not None and any(
|
||||
index < first_created_index for index in listen_indices
|
||||
)
|
||||
listen_between_responses = (
|
||||
first_done_index is not None
|
||||
and second_created_index is not None
|
||||
and any(first_done_index < index < second_created_index for index in listen_indices)
|
||||
)
|
||||
listen_after_last_done = last_done_index is not None and any(index > last_done_index for index in listen_indices)
|
||||
listen_after_response_before_commit = (
|
||||
last_done_index is not None
|
||||
and commit_index is not None
|
||||
and any(last_done_index < index < commit_index for index in listen_indices)
|
||||
)
|
||||
final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices)
|
||||
transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries)
|
||||
second_response_transcript = (
|
||||
str(response_summaries[1].get("transcript") or "") if len(response_summaries) >= 2 else ""
|
||||
)
|
||||
second_response_transcript_expectation_ok = not expect_second_response_substring or _normalize_text(
|
||||
expect_second_response_substring
|
||||
) in _normalize_text(second_response_transcript)
|
||||
error_events = [
|
||||
event
|
||||
for event in events
|
||||
if event.get("type") == "error"
|
||||
or str(event.get("type") or "").endswith(".cancelled")
|
||||
or str(event.get("type") or "").endswith(".truncated")
|
||||
]
|
||||
cancelled_count = sum(
|
||||
1
|
||||
for event in events
|
||||
if event.get("type") == "response.done"
|
||||
and isinstance(event.get("response"), dict)
|
||||
and event["response"].get("status") == "cancelled"
|
||||
)
|
||||
result_ok = result.get("ok") is True
|
||||
common_contract_ok = bool(
|
||||
result_ok
|
||||
and not error_events
|
||||
and cancelled_count == 0
|
||||
and enough_responses
|
||||
and response_lifecycle_ok
|
||||
and multi_delta_ok
|
||||
and response_audio_contract_ok
|
||||
and response_before_final_commit
|
||||
and listen_after_response_before_commit
|
||||
and final_listen_after_commit
|
||||
)
|
||||
mode_contract_ok = validation_mode == "model-policy" or (
|
||||
second_response_before_final_commit
|
||||
and listen_before_first_response
|
||||
and listen_between_responses
|
||||
and listen_after_last_done
|
||||
and second_response_transcript_expectation_ok
|
||||
)
|
||||
ok = common_contract_ok and mode_contract_ok
|
||||
return {
|
||||
"ok": ok,
|
||||
"result_ok": result_ok,
|
||||
"validation_mode": validation_mode,
|
||||
"event_counts": _event_counts(events),
|
||||
"response_ids": response_ids,
|
||||
"response_summaries": response_summaries,
|
||||
"min_responses": min_responses,
|
||||
"effective_min_responses": effective_min_responses,
|
||||
"min_audio_deltas_per_response": min_audio_deltas_per_response,
|
||||
"enough_responses": enough_responses,
|
||||
"response_lifecycle_ok": response_lifecycle_ok,
|
||||
"multi_delta_ok": multi_delta_ok,
|
||||
"response_audio_contract_ok": response_audio_contract_ok,
|
||||
"response_before_final_commit": response_before_final_commit,
|
||||
"second_response_before_final_commit": second_response_before_final_commit,
|
||||
"listen_before_first_response": listen_before_first_response,
|
||||
"listen_between_responses": listen_between_responses,
|
||||
"listen_after_last_done": listen_after_last_done,
|
||||
"listen_after_response_before_commit": listen_after_response_before_commit,
|
||||
"final_listen_after_commit": final_listen_after_commit,
|
||||
"transcript": transcript,
|
||||
"second_response_transcript": second_response_transcript,
|
||||
"expect_second_response_substring": expect_second_response_substring,
|
||||
"second_response_transcript_expectation_ok": second_response_transcript_expectation_ok,
|
||||
"error_count": len(error_events),
|
||||
"cancelled_count": cancelled_count,
|
||||
"compact_sequence": _compact_sequence(events),
|
||||
"output_dir": str(output_dir),
|
||||
}
|
||||
|
||||
|
||||
async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
|
||||
input_wav = _canonical_path(args.input_wav)
|
||||
output_dir = _canonical_path(args.output_dir)
|
||||
if not input_wav.is_file():
|
||||
raise ValueError(f"input WAV does not exist: {input_wav}")
|
||||
input_sha256 = hashlib.sha256(input_wav.read_bytes()).hexdigest()
|
||||
if args.input_sha256:
|
||||
input_sha256 = _validate_input_sha256(input_wav, args.input_sha256)
|
||||
command = [
|
||||
sys.executable,
|
||||
str(DEMO_PATH),
|
||||
"--url",
|
||||
args.url,
|
||||
"--model",
|
||||
args.model,
|
||||
"--input-wav",
|
||||
str(input_wav),
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
"--chunk-ms",
|
||||
str(args.chunk_ms),
|
||||
"--timeout-s",
|
||||
str(args.timeout_s),
|
||||
"--session-id",
|
||||
f"duplex-soft-interrupt-{uuid.uuid4().hex}",
|
||||
]
|
||||
command.extend(["--ref-audio", str(_canonical_path(args.ref_audio))])
|
||||
if args.require_audio:
|
||||
command.append("--require-audio")
|
||||
if args.no_realtime_pacing:
|
||||
command.append("--no-realtime-pacing")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
cwd=str(REPO_ROOT),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=args.timeout_s + 30.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
stdout_bytes, stderr_bytes = await process.communicate()
|
||||
return {
|
||||
"ok": False,
|
||||
"returncode": -1,
|
||||
"stdout_tail": stdout_bytes.decode(errors="replace")[-4000:],
|
||||
"stderr_tail": (stderr_bytes.decode(errors="replace") + "\nclient timeout")[-4000:],
|
||||
"output_dir": str(output_dir),
|
||||
}
|
||||
|
||||
summary = summarize_artifacts(
|
||||
output_dir=output_dir,
|
||||
validation_mode=args.validation_mode,
|
||||
min_responses=args.min_responses,
|
||||
min_audio_deltas_per_response=args.min_audio_deltas_per_response,
|
||||
expect_second_response_substring=args.expect_second_response_substring,
|
||||
)
|
||||
summary.update(
|
||||
{
|
||||
"returncode": process.returncode if process.returncode is not None else -1,
|
||||
"input_wav": str(input_wav),
|
||||
"input_sha256": input_sha256,
|
||||
"stdout_tail": stdout_bytes.decode(errors="replace")[-4000:],
|
||||
"stderr_tail": stderr_bytes.decode(errors="replace")[-4000:],
|
||||
}
|
||||
)
|
||||
summary["ok"] = bool(summary["ok"] and summary["returncode"] == 0)
|
||||
summary_output = Path(args.summary_output) if args.summary_output else output_dir / "summary.json"
|
||||
summary_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
summary_output.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return summary
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1")
|
||||
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
||||
parser.add_argument("--input-wav", required=True)
|
||||
parser.add_argument("--ref-audio", required=True)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--summary-output")
|
||||
parser.add_argument("--chunk-ms", type=int, default=200)
|
||||
parser.add_argument("--timeout-s", type=float, default=120.0)
|
||||
parser.add_argument("--require-audio", action="store_true")
|
||||
parser.add_argument("--no-realtime-pacing", action="store_true")
|
||||
parser.add_argument(
|
||||
"--validation-mode",
|
||||
choices=("model-policy", "response-required"),
|
||||
default="model-policy",
|
||||
)
|
||||
parser.add_argument("--min-responses", type=int, default=2)
|
||||
parser.add_argument("--min-audio-deltas-per-response", type=int, default=2)
|
||||
parser.add_argument("--input-sha256")
|
||||
parser.add_argument("--expect-second-response-substring")
|
||||
args = parser.parse_args()
|
||||
if args.validation_mode == "response-required" and args.min_responses < 2:
|
||||
parser.error("--min-responses must be at least 2")
|
||||
if args.validation_mode == "response-required" and not args.input_sha256:
|
||||
parser.error("--input-sha256 is required in response-required mode")
|
||||
if args.validation_mode == "response-required" and not args.expect_second_response_substring:
|
||||
parser.error("--expect-second-response-substring is required in response-required mode")
|
||||
if args.min_audio_deltas_per_response < 1:
|
||||
parser.error("--min-audio-deltas-per-response must be positive")
|
||||
return args
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = asyncio.run(run_soft_interrupt(parse_args()))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
import torch
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.engine import EngineCoreRequest
|
||||
@@ -64,6 +65,48 @@ def test_build_add_request_message_preserves_additional_information(mocker: Mock
|
||||
output_processor.add_request.assert_not_called()
|
||||
|
||||
|
||||
def test_build_add_request_message_preserves_model_intermediate_buffer(mocker: MockerFixture):
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
params = SamplingParams(max_tokens=8)
|
||||
engine.default_sampling_params_list = [params]
|
||||
engine.stage_metadata = [StageRuntimeInfo(final_output=False, final_output_type=None, stage_type="llm")]
|
||||
engine.supported_tasks = ("speech",)
|
||||
|
||||
input_processor = mocker.Mock()
|
||||
input_processor.process_inputs.return_value = _make_engine_core_request()
|
||||
engine.input_processor = input_processor
|
||||
|
||||
output_processor = mocker.Mock()
|
||||
engine.output_processors = [output_processor]
|
||||
|
||||
hidden = torch.arange(6, dtype=torch.float32).reshape(2, 3)
|
||||
prompt = {
|
||||
"prompt_token_ids": [1, 1, 1],
|
||||
"model_intermediate_buffer": {
|
||||
"ids": {"tts": [11, 12]},
|
||||
"hidden_states": {"tts": hidden},
|
||||
},
|
||||
}
|
||||
|
||||
msg = engine._build_add_request_message(
|
||||
request_id="req-1",
|
||||
prompt=prompt,
|
||||
sampling_params_list=[params],
|
||||
final_stage_id=0,
|
||||
arrival_time=0.0,
|
||||
)
|
||||
|
||||
request = msg.prompt
|
||||
assert isinstance(request, OmniEngineCoreRequest)
|
||||
assert request.additional_information is not None
|
||||
assert request.additional_information.entries["global_request_id"].list_data == ["req-1"]
|
||||
assert request.additional_information.entries["omni_final_stage_id"].scalar_data == 0
|
||||
assert isinstance(request.model_intermediate_buffer, dict)
|
||||
info = request.model_intermediate_buffer
|
||||
assert info["ids"]["tts"] == [11, 12]
|
||||
assert torch.equal(info["hidden_states"]["tts"], hidden)
|
||||
|
||||
|
||||
def test_build_add_request_message_with_resumable_streaming(mocker: MockerFixture):
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
params = SamplingParams(max_tokens=8)
|
||||
|
||||
@@ -4,13 +4,33 @@ Focuses on the critical behavior: when the orchestrator thread dies,
|
||||
subsequent attempts to collect output raise RuntimeError.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from vllm_omni.engine.async_engine_utils import weak_shutdown_async_omni_engine
|
||||
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
|
||||
from vllm_omni.engine.messages import ErrorMessage, OutputMessage
|
||||
from vllm_omni.engine.messages import (
|
||||
CollectiveRPCResultMessage,
|
||||
ErrorMessage,
|
||||
OutputMessage,
|
||||
)
|
||||
from vllm_omni.engine.rpc_result_router import CorrelatedRpcClient
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import (
|
||||
DuplexControlClient,
|
||||
DuplexControlRequestError,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import (
|
||||
AppendDuplexInputMessage,
|
||||
DuplexControlResultMessage,
|
||||
DuplexFence,
|
||||
SignalDuplexTurnMessage,
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -26,6 +46,33 @@ def _make_engine(output_queue, mocker: MockerFixture, *, thread_alive: bool = Tr
|
||||
return engine
|
||||
|
||||
|
||||
def test_weak_shutdown_closes_rpc_router_before_joining_orchestrator(mocker: MockerFixture):
|
||||
request_queue = mocker.MagicMock()
|
||||
output_queue = mocker.MagicMock()
|
||||
rpc_output_queue = mocker.MagicMock()
|
||||
router = mocker.MagicMock()
|
||||
orchestrator_thread = mocker.MagicMock()
|
||||
orchestrator_thread.is_alive.return_value = True
|
||||
|
||||
def assert_router_closed_before_join(*args, **kwargs):
|
||||
router.close.assert_called_once_with()
|
||||
|
||||
orchestrator_thread.join.side_effect = assert_router_closed_before_join
|
||||
|
||||
weak_shutdown_async_omni_engine(
|
||||
orchestrator_thread,
|
||||
request_queue,
|
||||
output_queue,
|
||||
rpc_output_queue,
|
||||
router,
|
||||
)
|
||||
|
||||
request_queue.sync_q.put.assert_called_once()
|
||||
assert request_queue.sync_q.put.call_args.kwargs["timeout"] > 0
|
||||
orchestrator_thread.join.assert_called_once()
|
||||
assert orchestrator_thread.join.call_args.kwargs["timeout"] > 0
|
||||
|
||||
|
||||
def test_try_get_output_raises_after_orchestrator_dies(mocker: MockerFixture):
|
||||
"""Draining remaining results then hitting an empty queue with a dead
|
||||
orchestrator must raise RuntimeError so callers know the pipeline is gone."""
|
||||
@@ -56,18 +103,17 @@ def test_try_get_output_raises_after_orchestrator_dies(mocker: MockerFixture):
|
||||
@pytest.mark.asyncio
|
||||
async def test_try_get_output_async_raises_after_orchestrator_dies(mocker: MockerFixture):
|
||||
"""Same scenario as above but for the async variant."""
|
||||
mock_queue = mocker.MagicMock()
|
||||
mock_queue.sync_q.get_nowait.side_effect = [
|
||||
raw_queue = queue.Queue()
|
||||
raw_queue.put_nowait(
|
||||
OutputMessage(
|
||||
request_id="r1",
|
||||
stage_id=0,
|
||||
engine_outputs=OmniRequestOutput(request_id="r1"),
|
||||
finished=False,
|
||||
),
|
||||
queue.Empty,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
engine = _make_engine(mock_queue, mocker, thread_alive=True)
|
||||
engine = _make_engine(SimpleNamespace(sync_q=raw_queue), mocker, thread_alive=True)
|
||||
|
||||
assert (await engine.try_get_output_async()).request_id == "r1"
|
||||
|
||||
@@ -102,12 +148,495 @@ async def test_fatal_error_message_surfaces_through_try_get_output_async(mocker:
|
||||
"""Async variant of the fatal error message test."""
|
||||
fatal_msg = ErrorMessage(error="Orchestrator thread crashed", fatal=True)
|
||||
|
||||
mock_queue = mocker.MagicMock()
|
||||
mock_queue.sync_q.get_nowait.return_value = fatal_msg
|
||||
raw_queue = queue.Queue()
|
||||
raw_queue.put_nowait(fatal_msg)
|
||||
|
||||
engine = _make_engine(mock_queue, mocker, thread_alive=False)
|
||||
engine = _make_engine(SimpleNamespace(sync_q=raw_queue), mocker, thread_alive=False)
|
||||
|
||||
msg = await engine.try_get_output_async()
|
||||
assert msg is not None
|
||||
assert msg.type == "error"
|
||||
assert msg.fatal is True
|
||||
|
||||
|
||||
def test_output_remains_on_shared_output_path(mocker: MockerFixture):
|
||||
raw_queue = queue.Queue()
|
||||
raw_queue.put_nowait(
|
||||
OutputMessage(
|
||||
request_id="legacy-duplex-request",
|
||||
stage_id=0,
|
||||
engine_outputs=OmniRequestOutput(request_id="legacy-duplex-request"),
|
||||
finished=False,
|
||||
)
|
||||
)
|
||||
engine = _make_engine(SimpleNamespace(sync_q=raw_queue), mocker)
|
||||
|
||||
output = engine.try_get_output(timeout=0.01)
|
||||
|
||||
assert output.request_id == "legacy-duplex-request"
|
||||
|
||||
|
||||
def test_duplex_control_client_uses_engine_owned_correlated_transport():
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine._duplex_control_client = None
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(queue.Queue(), queue.Queue())
|
||||
|
||||
try:
|
||||
client = engine._get_duplex_control_client()
|
||||
|
||||
assert isinstance(client, DuplexControlClient)
|
||||
assert client._transport is engine._correlated_rpc_client
|
||||
assert not hasattr(engine, "_submit_rpc_and_wait")
|
||||
finally:
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_duplex_control_api_requires_explicit_fence():
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.open_duplex_session("sid")
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.append_duplex_input("sid", mode="append_audio_chunk", payload={})
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.signal_duplex_turn("sid", event="turn.end")
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.close_duplex_session("sid")
|
||||
|
||||
|
||||
def test_open_duplex_session_waits_for_control_ack(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
control_result = DuplexControlResultMessage(
|
||||
control_id="ctrl-1",
|
||||
fence=DuplexFence("sid"),
|
||||
operation="open",
|
||||
session_id="sid",
|
||||
ok=False,
|
||||
stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False}}],
|
||||
unsupported_count=1,
|
||||
error_count=0,
|
||||
)
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-1"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1)
|
||||
msg = request_q.get(timeout=1)
|
||||
assert msg.type == "open_duplex_session"
|
||||
assert msg.control_id == "ctrl-1"
|
||||
rpc_q.put(control_result)
|
||||
with pytest.raises(DuplexControlRequestError) as exc_info:
|
||||
pending.result(timeout=1)
|
||||
|
||||
assert exc_info.value.result["unsupported_count"] == 1
|
||||
assert exc_info.value.result["stage_results"][0]["result"]["supported"] is False
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_signal_duplex_turn_message_carries_next_fence(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
cancelled_fence = DuplexFence("sid-cancel", epoch=4, turn_id=2)
|
||||
next_fence = DuplexFence("sid-cancel", epoch=5, turn_id=2)
|
||||
control_result = DuplexControlResultMessage(
|
||||
control_id="ctrl-signal",
|
||||
fence=cancelled_fence,
|
||||
operation="signal",
|
||||
session_id="sid-cancel",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-signal"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(
|
||||
engine.signal_duplex_turn,
|
||||
"sid-cancel",
|
||||
event="input.cancel",
|
||||
fence=cancelled_fence,
|
||||
next_fence=next_fence,
|
||||
session_config={"temperature": 0.0},
|
||||
timeout=1,
|
||||
)
|
||||
msg = request_q.get(timeout=1)
|
||||
assert isinstance(msg, SignalDuplexTurnMessage)
|
||||
assert msg.fence == cancelled_fence
|
||||
assert msg.next_fence == next_fence
|
||||
assert msg.session_config == {"temperature": 0.0}
|
||||
rpc_q.put(control_result)
|
||||
assert pending.result(timeout=1)["ok"] is True
|
||||
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_async_append_does_not_stop_executor_callable(mocker: MockerFixture):
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
completed = threading.Event()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
|
||||
def blocking_append(*args, **kwargs):
|
||||
del args, kwargs
|
||||
entered.set()
|
||||
release.wait(timeout=1)
|
||||
completed.set()
|
||||
return {}
|
||||
|
||||
mocker.patch.object(engine, "append_duplex_input", side_effect=blocking_append)
|
||||
task = asyncio.create_task(
|
||||
engine.append_duplex_input_async(
|
||||
"sid-executor-cancel",
|
||||
mode="append_audio_chunk",
|
||||
payload=b"audio",
|
||||
fence=DuplexFence("sid-executor-cancel"),
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(entered.wait, 1)
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
release.set()
|
||||
|
||||
assert await asyncio.to_thread(completed.wait, 1)
|
||||
|
||||
|
||||
def test_duplex_controls_route_out_of_order_without_global_lock():
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
first_fence = DuplexFence("sid", epoch=1)
|
||||
second_fence = DuplexFence("sid", epoch=2)
|
||||
first = AppendDuplexInputMessage(
|
||||
control_id="first",
|
||||
fence=first_fence,
|
||||
session_id="sid",
|
||||
expected_epoch=1,
|
||||
mode="audio",
|
||||
payload=b"first",
|
||||
)
|
||||
second = AppendDuplexInputMessage(
|
||||
control_id="second",
|
||||
fence=second_fence,
|
||||
session_id="sid",
|
||||
expected_epoch=2,
|
||||
mode="audio",
|
||||
payload=b"second",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
client = engine._get_duplex_control_client()
|
||||
first_result = executor.submit(client.execute, first, timeout=1)
|
||||
second_result = executor.submit(client.execute, second, timeout=1)
|
||||
submitted = {request_q.get(timeout=1).control_id, request_q.get(timeout=1).control_id}
|
||||
assert submitted == {"first", "second"}
|
||||
rpc_q.put(
|
||||
DuplexControlResultMessage(
|
||||
control_id="second",
|
||||
fence=second_fence,
|
||||
operation="append",
|
||||
session_id="sid",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
)
|
||||
rpc_q.put(
|
||||
DuplexControlResultMessage(
|
||||
control_id="first",
|
||||
fence=first_fence,
|
||||
operation="append",
|
||||
session_id="sid",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
)
|
||||
|
||||
assert first_result.result(timeout=1)["fence"] == first_fence
|
||||
assert second_result.result(timeout=1)["fence"] == second_fence
|
||||
|
||||
assert not hasattr(engine, "_rpc_lock")
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_terminal_rpc_router_rejects_without_enqueuing_new_control():
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
pending_message = AppendDuplexInputMessage(
|
||||
control_id="pending",
|
||||
fence=DuplexFence("sid"),
|
||||
session_id="sid",
|
||||
mode="audio",
|
||||
payload=b"audio",
|
||||
)
|
||||
message = AppendDuplexInputMessage(
|
||||
control_id="after-fatal",
|
||||
fence=DuplexFence("sid"),
|
||||
session_id="sid",
|
||||
mode="audio",
|
||||
payload=b"audio",
|
||||
)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine._get_duplex_control_client().execute, pending_message, timeout=1)
|
||||
assert request_q.get(timeout=1).control_id == "pending"
|
||||
rpc_q.put(ErrorMessage(error="orchestrator failed", fatal=True))
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
pending.result(timeout=1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
engine._get_duplex_control_client().execute(message, timeout=1)
|
||||
|
||||
assert request_q.empty()
|
||||
finally:
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_collective_and_duplex_results_share_router_without_swapping(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
fence = DuplexFence("sid")
|
||||
duplex = AppendDuplexInputMessage(
|
||||
control_id="duplex-control",
|
||||
fence=fence,
|
||||
session_id="sid",
|
||||
mode="audio",
|
||||
payload=b"audio",
|
||||
)
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="collective-rpc"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
duplex_result = executor.submit(engine._get_duplex_control_client().execute, duplex, timeout=1)
|
||||
collective_result = executor.submit(engine.collective_rpc, "health", timeout=1)
|
||||
submitted_types = {request_q.get(timeout=1).type, request_q.get(timeout=1).type}
|
||||
assert submitted_types == {"append_duplex_input", "collective_rpc"}
|
||||
rpc_q.put(
|
||||
CollectiveRPCResultMessage(
|
||||
rpc_id="collective-rpc",
|
||||
method="health",
|
||||
stage_ids=[0],
|
||||
results=["healthy"],
|
||||
)
|
||||
)
|
||||
rpc_q.put(
|
||||
DuplexControlResultMessage(
|
||||
control_id="duplex-control",
|
||||
fence=fence,
|
||||
operation="append",
|
||||
session_id="sid",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
)
|
||||
|
||||
assert collective_result.result(timeout=1) == ["healthy"]
|
||||
assert duplex_result.result(timeout=1)["fence"] == fence
|
||||
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixture):
|
||||
class SignallingQueue(queue.Queue):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(maxsize=1)
|
||||
self.put_attempted = threading.Event()
|
||||
|
||||
def put(self, item, block=True, timeout=None):
|
||||
self.put_attempted.set()
|
||||
return super().put(item, block=block, timeout=timeout)
|
||||
|
||||
request_q = SignallingQueue()
|
||||
request_q.put("queue-is-full")
|
||||
request_q.put_attempted.clear()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="blocked-rpc"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine.collective_rpc, "health", timeout=1)
|
||||
assert request_q.put_attempted.wait(timeout=1)
|
||||
assert not pending.done()
|
||||
|
||||
assert request_q.get(timeout=1) == "queue-is-full"
|
||||
assert request_q.get(timeout=1).rpc_id == "blocked-rpc"
|
||||
rpc_q.put(
|
||||
CollectiveRPCResultMessage(
|
||||
rpc_id="blocked-rpc",
|
||||
method="health",
|
||||
stage_ids=[0],
|
||||
results=["healthy"],
|
||||
)
|
||||
)
|
||||
assert pending.result(timeout=1) == ["healthy"]
|
||||
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_open_duplex_session_raises_on_stage_control_error(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
control_result = DuplexControlResultMessage(
|
||||
control_id="ctrl-error",
|
||||
fence=DuplexFence("sid"),
|
||||
operation="open",
|
||||
session_id="sid",
|
||||
ok=False,
|
||||
stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False, "error": "boom"}}],
|
||||
unsupported_count=1,
|
||||
error_count=1,
|
||||
)
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-error"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1)
|
||||
request_q.get(timeout=1)
|
||||
rpc_q.put(control_result)
|
||||
with pytest.raises(RuntimeError, match="duplex open failed"):
|
||||
pending.result(timeout=1)
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_shutdown_does_not_race_runtime_cleanup_with_live_orchestrator(mocker: MockerFixture):
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine._shutdown_called = False
|
||||
engine._weak_finalizer = None
|
||||
engine.request_queue = mocker.MagicMock()
|
||||
engine.request_queue.sync_q.put.side_effect = queue.Full
|
||||
engine.output_queue = mocker.MagicMock()
|
||||
engine.rpc_output_queue = mocker.MagicMock()
|
||||
engine._correlated_rpc_client = mocker.MagicMock()
|
||||
engine.orchestrator_thread = mocker.MagicMock()
|
||||
engine.orchestrator_thread.is_alive.return_value = True
|
||||
engine._runtime = mocker.MagicMock()
|
||||
|
||||
engine.shutdown()
|
||||
|
||||
engine.request_queue.sync_q.put.assert_called_once()
|
||||
assert engine.request_queue.sync_q.put.call_args.kwargs["timeout"] > 0
|
||||
assert engine.orchestrator_thread.join.call_args_list[0].kwargs["timeout"] > 0
|
||||
engine._correlated_rpc_client.close.assert_called_once_with()
|
||||
engine.request_queue.close.assert_called_once_with()
|
||||
engine.output_queue.close.assert_called_once_with()
|
||||
engine.rpc_output_queue.close.assert_called_once_with()
|
||||
engine._runtime.shutdown.assert_not_called()
|
||||
|
||||
|
||||
def test_shutdown_releases_runtime_after_orchestrator_stops(mocker: MockerFixture):
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine._shutdown_called = False
|
||||
engine._weak_finalizer = None
|
||||
engine.request_queue = mocker.MagicMock()
|
||||
engine.output_queue = mocker.MagicMock()
|
||||
engine.rpc_output_queue = mocker.MagicMock()
|
||||
engine._correlated_rpc_client = mocker.MagicMock()
|
||||
engine.orchestrator_thread = mocker.MagicMock()
|
||||
engine.orchestrator_thread.is_alive.side_effect = [True, False]
|
||||
engine._runtime = mocker.MagicMock()
|
||||
|
||||
engine.shutdown()
|
||||
|
||||
engine.orchestrator_thread.join.assert_called_once()
|
||||
engine._runtime.shutdown.assert_called_once_with()
|
||||
|
||||
|
||||
def test_shutdown_defers_runtime_release_until_live_orchestrator_stops(mocker: MockerFixture):
|
||||
orchestrator_stopped = threading.Event()
|
||||
runtime_released = threading.Event()
|
||||
|
||||
class ControllableOrchestratorThread:
|
||||
def __init__(self) -> None:
|
||||
self.join_timeouts: list[float | None] = []
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return not orchestrator_stopped.is_set()
|
||||
|
||||
def join(self, timeout: float | None = None) -> None:
|
||||
self.join_timeouts.append(timeout)
|
||||
if timeout is None:
|
||||
orchestrator_stopped.wait()
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine._shutdown_called = False
|
||||
engine._weak_finalizer = None
|
||||
engine.request_queue = mocker.MagicMock()
|
||||
engine.output_queue = mocker.MagicMock()
|
||||
engine.rpc_output_queue = mocker.MagicMock()
|
||||
engine._correlated_rpc_client = mocker.MagicMock()
|
||||
engine.orchestrator_thread = ControllableOrchestratorThread()
|
||||
engine._runtime = mocker.MagicMock()
|
||||
engine._runtime.shutdown.side_effect = runtime_released.set
|
||||
|
||||
try:
|
||||
engine.shutdown()
|
||||
|
||||
engine._runtime.shutdown.assert_not_called()
|
||||
orchestrator_stopped.set()
|
||||
assert runtime_released.wait(timeout=1)
|
||||
engine._runtime.shutdown.assert_called_once_with()
|
||||
assert engine.orchestrator_thread.join_timeouts[0] is not None
|
||||
assert engine.orchestrator_thread.join_timeouts[-1] is None
|
||||
finally:
|
||||
orchestrator_stopped.set()
|
||||
|
||||
|
||||
def test_weak_shutdown_is_bounded_when_request_queue_is_full(mocker: MockerFixture):
|
||||
request_queue = mocker.MagicMock()
|
||||
request_queue.sync_q.put.side_effect = queue.Full
|
||||
output_queue = mocker.MagicMock()
|
||||
rpc_output_queue = mocker.MagicMock()
|
||||
router = mocker.MagicMock()
|
||||
orchestrator_thread = mocker.MagicMock()
|
||||
orchestrator_thread.is_alive.return_value = True
|
||||
|
||||
weak_shutdown_async_omni_engine(
|
||||
orchestrator_thread,
|
||||
request_queue,
|
||||
output_queue,
|
||||
rpc_output_queue,
|
||||
router,
|
||||
)
|
||||
|
||||
request_queue.sync_q.put.assert_called_once()
|
||||
assert request_queue.sync_q.put.call_args.kwargs["timeout"] > 0
|
||||
orchestrator_thread.join.assert_called_once()
|
||||
assert orchestrator_thread.join.call_args.kwargs["timeout"] > 0
|
||||
request_queue.close.assert_called_once_with()
|
||||
output_queue.close.assert_called_once_with()
|
||||
rpc_output_queue.close.assert_called_once_with()
|
||||
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.engine.messages import (
|
||||
CollectiveRPCRequestMessage,
|
||||
CollectiveRPCResultMessage,
|
||||
ErrorMessage,
|
||||
)
|
||||
from vllm_omni.engine.rpc_result_router import CorrelatedRpcClient
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _request(rpc_id: str) -> CollectiveRPCRequestMessage:
|
||||
return CollectiveRPCRequestMessage(
|
||||
rpc_id=rpc_id,
|
||||
method="health",
|
||||
timeout=1,
|
||||
args=(),
|
||||
kwargs={},
|
||||
stage_ids=None,
|
||||
)
|
||||
|
||||
|
||||
def test_correlated_rpc_client_unregisters_timeout_before_late_result() -> None:
|
||||
request_queue: queue.Queue = queue.Queue()
|
||||
result_queue: queue.Queue = queue.Queue()
|
||||
client = CorrelatedRpcClient(request_queue, result_queue)
|
||||
|
||||
try:
|
||||
with pytest.raises(TimeoutError, match="first timed out"):
|
||||
client.execute(
|
||||
("collective", "first"),
|
||||
_request("first"),
|
||||
timeout=0.01,
|
||||
timeout_message="first timed out",
|
||||
)
|
||||
assert request_queue.get_nowait().rpc_id == "first"
|
||||
|
||||
result_queue.put(
|
||||
CollectiveRPCResultMessage(
|
||||
rpc_id="first",
|
||||
method="health",
|
||||
stage_ids=[0],
|
||||
results=["late"],
|
||||
)
|
||||
)
|
||||
result_queue.put(
|
||||
CollectiveRPCResultMessage(
|
||||
rpc_id="second",
|
||||
method="health",
|
||||
stage_ids=[0],
|
||||
results=["current"],
|
||||
)
|
||||
)
|
||||
current = client.execute(
|
||||
("collective", "second"),
|
||||
_request("second"),
|
||||
timeout=1,
|
||||
timeout_message="second timed out",
|
||||
)
|
||||
|
||||
assert isinstance(current, CollectiveRPCResultMessage)
|
||||
assert current.results == ["current"]
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_correlated_rpc_client_rejects_after_fatal_without_enqueuing() -> None:
|
||||
request_queue: queue.Queue = queue.Queue()
|
||||
result_queue: queue.Queue = queue.Queue()
|
||||
client = CorrelatedRpcClient(request_queue, result_queue)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(
|
||||
client.execute,
|
||||
("collective", "pending"),
|
||||
_request("pending"),
|
||||
timeout=1,
|
||||
timeout_message="unexpected timeout",
|
||||
)
|
||||
assert request_queue.get(timeout=1).rpc_id == "pending"
|
||||
result_queue.put(ErrorMessage(error="orchestrator failed", fatal=True))
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
pending.result(timeout=1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
client.execute(
|
||||
("collective", "after-fatal"),
|
||||
_request("after-fatal"),
|
||||
timeout=1,
|
||||
timeout_message="unexpected timeout",
|
||||
)
|
||||
assert request_queue.empty()
|
||||
finally:
|
||||
client.close()
|
||||
@@ -0,0 +1,102 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _assert_isolated_import_succeeds(script: str) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_stable_engine_imports_do_not_load_experimental_duplex() -> None:
|
||||
_assert_isolated_import_succeeds("""
|
||||
import sys
|
||||
|
||||
import vllm_omni.engine.async_omni_engine
|
||||
import vllm_omni.engine.orchestrator
|
||||
import vllm_omni.entrypoints.async_omni
|
||||
|
||||
loaded = sorted(
|
||||
name
|
||||
for name in sys.modules
|
||||
if name == "vllm_omni.experimental.fullduplex"
|
||||
or name.startswith("vllm_omni.experimental.fullduplex.")
|
||||
)
|
||||
if loaded:
|
||||
raise SystemExit("stable imports loaded experimental duplex: " + ", ".join(loaded))
|
||||
""")
|
||||
|
||||
|
||||
def test_stable_engine_does_not_expose_duplex_contract_modules() -> None:
|
||||
_assert_isolated_import_succeeds("""
|
||||
import importlib.util
|
||||
|
||||
from vllm_omni.engine import messages
|
||||
|
||||
legacy_modules = (
|
||||
"vllm_omni.engine.duplex_contracts",
|
||||
"vllm_omni.engine.duplex_lease",
|
||||
"vllm_omni.engine.resumable",
|
||||
)
|
||||
present = [name for name in legacy_modules if importlib.util.find_spec(name) is not None]
|
||||
if present:
|
||||
raise SystemExit("stable duplex modules still exist: " + ", ".join(present))
|
||||
|
||||
duplex_exports = sorted(name for name in vars(messages) if name.startswith("Duplex"))
|
||||
if duplex_exports:
|
||||
raise SystemExit("stable messages still expose duplex contracts: " + ", ".join(duplex_exports))
|
||||
""")
|
||||
|
||||
|
||||
def test_stable_outputs_do_not_declare_duplex_decision_field() -> None:
|
||||
_assert_isolated_import_succeeds("""
|
||||
import dataclasses
|
||||
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
fields = {field.name for field in dataclasses.fields(OmniRequestOutput)}
|
||||
if "duplex_output_decision" in fields:
|
||||
raise SystemExit("stable output declares duplex_output_decision")
|
||||
""")
|
||||
|
||||
|
||||
def test_stable_model_executor_does_not_expose_duplex_helper_module() -> None:
|
||||
_assert_isolated_import_succeeds("""
|
||||
import importlib.util
|
||||
|
||||
if importlib.util.find_spec("vllm_omni.model_executor.duplex") is not None:
|
||||
raise SystemExit("stable model_executor still exposes duplex helper module")
|
||||
""")
|
||||
|
||||
|
||||
def test_runtime_package_does_not_bundle_the_browser_demo() -> None:
|
||||
assert not (REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "web").exists()
|
||||
|
||||
|
||||
def test_experimental_engine_uses_canonical_contract_module_names() -> None:
|
||||
engine_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "engine"
|
||||
core_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "core"
|
||||
|
||||
assert (engine_dir / "contracts.py").is_file()
|
||||
assert (engine_dir / "lease.py").is_file()
|
||||
assert (engine_dir / "messages.py").is_file()
|
||||
assert not (engine_dir / "duplex_lease.py").exists()
|
||||
assert not (engine_dir / "duplex_types.py").exists()
|
||||
assert not (core_dir / "identity.py").exists()
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator.messages import ReplicaStatus
|
||||
from vllm_omni.engine.membership_controller import MembershipController
|
||||
from vllm_omni.engine.stage_pool import StagePool
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -45,10 +46,10 @@ class FakePool:
|
||||
def attach_load_balancer(self, lb):
|
||||
self.lb = lb
|
||||
|
||||
def add_client(self, input_addr, client):
|
||||
self.added.append((input_addr, client))
|
||||
def add_client(self, input_addr, client, *, replica_id=None):
|
||||
self.added.append((input_addr, client, replica_id))
|
||||
self.clients.append(client)
|
||||
return len(self.clients) - 1
|
||||
return len(self.clients) - 1 if replica_id is None else replica_id
|
||||
|
||||
def invalidate_addr(self, input_addr):
|
||||
self.invalidated.append(input_addr)
|
||||
@@ -155,4 +156,35 @@ async def test_do_register_offloads_remote_factory(monkeypatch):
|
||||
|
||||
assert factory_thread_ids
|
||||
assert factory_thread_ids[0] != threading.get_ident()
|
||||
assert pool.added[0][0] == "tcp://stage-0-replica-1"
|
||||
assert pool.added[0] == ("tcp://stage-0-replica-1", pool.clients[0], 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_then_register_restores_coordinator_replica_slot(monkeypatch):
|
||||
class Client:
|
||||
def __init__(self, input_addr: str) -> None:
|
||||
self.client_addresses = {"input_address": input_addr}
|
||||
self.shutdown_calls = 0
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_calls += 1
|
||||
|
||||
old_clients = [Client("tcp://old-0"), Client("tcp://old-1")]
|
||||
replacement = Client("tcp://new-1")
|
||||
pool = StagePool(0, old_clients)
|
||||
|
||||
def factory(stage_id: int, replica_id: int):
|
||||
assert (stage_id, replica_id) == (0, 1)
|
||||
return replacement
|
||||
|
||||
controller = _controller(monkeypatch, pool, FakeHub(), remote_replica_factory=factory)
|
||||
await controller.handle_unregister(0, "tcp://old-0")
|
||||
await controller.handle_unregister(0, "tcp://old-1")
|
||||
|
||||
await controller.handle_register(0, 1)
|
||||
await controller.drain_tasks(timeout=1)
|
||||
|
||||
assert pool.clients == [None, replacement]
|
||||
assert pool.get_replica_id_by_addr("tcp://new-1") == 1
|
||||
assert pool.get_replica_id_by_addr("tcp://old-1") is None
|
||||
assert pool.available_replica_ids() == [1]
|
||||
@@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.outputs.mm_outputs import MultimodalPayload
|
||||
from vllm_omni.outputs.multimodal_accumulation import (
|
||||
drain_delta_payload,
|
||||
is_non_final_delta_audio_chunk,
|
||||
replace_snapshot_keys,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
|
||||
accumulated = MultimodalPayload.from_dict(
|
||||
{
|
||||
"audio": torch.tensor([1.0]),
|
||||
"meta.tts_is_last_chunk": torch.tensor([0]),
|
||||
"meta.turn_end": torch.tensor([0]),
|
||||
"meta.stable_request_value": "keep",
|
||||
}
|
||||
)
|
||||
incoming = MultimodalPayload.from_dict(
|
||||
{
|
||||
"audio": torch.tensor([2.0]),
|
||||
"meta.tts_is_last_chunk": torch.tensor([1]),
|
||||
"meta.turn_end": torch.tensor([1]),
|
||||
}
|
||||
)
|
||||
assert accumulated is not None
|
||||
assert incoming is not None
|
||||
|
||||
replace_snapshot_keys(accumulated, incoming)
|
||||
merged = accumulated.merged_with(incoming)
|
||||
|
||||
assert not is_non_final_delta_audio_chunk(merged, "audio")
|
||||
|
||||
drain_delta_payload(merged)
|
||||
|
||||
assert "audio" not in merged
|
||||
assert "meta.tts_is_last_chunk" not in merged
|
||||
assert "meta.turn_end" not in merged
|
||||
assert merged.metadata["meta.stable_request_value"] == "keep"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -38,6 +38,7 @@ class FakeStageClient:
|
||||
self.custom_process_input_func = None
|
||||
self.next_inputs = list(next_inputs or [])
|
||||
self.add_request_calls: list[tuple[Any, ...]] = []
|
||||
self.decoded_source_tokens: str | None = None
|
||||
self._engine_core_outputs = queue.Queue()
|
||||
|
||||
async def add_request_async(self, *args, **_kwargs) -> None:
|
||||
@@ -50,6 +51,9 @@ class FakeStageClient:
|
||||
return SimpleNamespace(outputs=[])
|
||||
|
||||
def process_engine_inputs(self, _source_outputs, prompt=None, streaming_context=None):
|
||||
decoder = getattr(streaming_context, "source_token_decoder", None)
|
||||
if callable(decoder):
|
||||
self.decoded_source_tokens = decoder([11, 12], skip_special_tokens=True)
|
||||
return list(self.next_inputs)
|
||||
|
||||
async def abort_requests_async(self, _request_ids: list[str]) -> None:
|
||||
@@ -66,6 +70,9 @@ class FakeStageClient:
|
||||
|
||||
|
||||
class FakeOutputProcessor:
|
||||
def __init__(self, tokenizer=None) -> None:
|
||||
self.tokenizer = tokenizer
|
||||
|
||||
def add_request(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -108,6 +115,11 @@ def _request_output(request_id: str) -> RequestOutput:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_text_prompt_uses_target_stage_input_processor() -> None:
|
||||
class SourceTokenizer:
|
||||
def decode(self, token_ids, *, skip_special_tokens):
|
||||
assert skip_special_tokens is True
|
||||
return ":".join(str(token_id) for token_id in token_ids)
|
||||
|
||||
stage0 = FakeStageClient(final_output=True)
|
||||
stage1 = FakeStageClient(
|
||||
final_output=True,
|
||||
@@ -117,7 +129,7 @@ async def test_forward_text_prompt_uses_target_stage_input_processor() -> None:
|
||||
StagePool(
|
||||
0,
|
||||
[stage0],
|
||||
output_processor=FakeOutputProcessor(),
|
||||
output_processor=FakeOutputProcessor(tokenizer=SourceTokenizer()),
|
||||
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
|
||||
),
|
||||
StagePool(
|
||||
@@ -150,6 +162,8 @@ async def test_forward_text_prompt_uses_target_stage_input_processor() -> None:
|
||||
|
||||
assert input_processor.calls
|
||||
assert input_processor.calls[0]["prompt"] == {"prompt": "hello", "multi_modal_data": {"video": ["frame"]}}
|
||||
assert stage1.decoded_source_tokens == "11:12"
|
||||
assert req_state.streaming.source_token_decoder is None
|
||||
assert stage1.add_request_calls
|
||||
submitted_request = stage1.add_request_calls[0][0]
|
||||
assert submitted_request.prompt_token_ids == [101, 102]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for OmniRequestState multimodal DELTA drain and consolidation guard."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
@@ -10,9 +11,11 @@ import torch
|
||||
from vllm.outputs import PoolingRequestOutput
|
||||
from vllm.sampling_params import RequestOutputKind
|
||||
from vllm.v1.engine import FinishReason
|
||||
from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor
|
||||
|
||||
from vllm_omni.outputs import output_processor
|
||||
from vllm_omni.outputs.output_modality import OutputModalityNames
|
||||
from vllm_omni.outputs.output_processor import OmniRequestState
|
||||
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -62,6 +65,65 @@ def test_init_empty_dict():
|
||||
assert _make_state(RequestOutputKind.DELTA).mm_accumulated == {}
|
||||
|
||||
|
||||
def test_streaming_update_resets_native_text_metrics_for_next_segment():
|
||||
state = _make_state(RequestOutputKind.DELTA)
|
||||
state.native_text_stats.num_generation_tokens = 12
|
||||
state.native_text_stats.first_token_ts = 10.0
|
||||
state.native_text_stats.last_token_ts = 10.2
|
||||
|
||||
state.apply_streaming_update(
|
||||
SimpleNamespace(
|
||||
final=False,
|
||||
prompt=None,
|
||||
prompt_token_ids=[],
|
||||
arrival_time=20.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert state.native_text_stats.arrival_time == 20.0
|
||||
assert state.native_text_stats.num_generation_tokens == 0
|
||||
assert state.native_text_stats.first_token_ts == 0.0
|
||||
assert state.native_text_stats.last_token_ts == 0.0
|
||||
|
||||
|
||||
def test_native_text_tpot_snapshot_matches_vllm_finished_metric_definition():
|
||||
calculate = getattr(output_processor, "_mean_time_per_output_token_ms", None)
|
||||
assert calculate is not None
|
||||
stats = SimpleNamespace(
|
||||
num_generation_tokens=4,
|
||||
first_token_ts=10.0,
|
||||
last_token_ts=10.045,
|
||||
)
|
||||
|
||||
assert calculate(stats) == pytest.approx(15.0)
|
||||
|
||||
|
||||
def test_native_text_metrics_include_segment_generation_token_count(monkeypatch):
|
||||
monkeypatch.setattr(VLLMOutputProcessor, "_update_stats_from_output", lambda *args, **kwargs: None)
|
||||
processor = object.__new__(MultimodalOutputProcessor)
|
||||
processor._native_text_metrics_by_request = {}
|
||||
processor.lora_states = {}
|
||||
state = _make_state(RequestOutputKind.DELTA)
|
||||
state.is_prefilling = False
|
||||
iteration_stats = MagicMock()
|
||||
|
||||
def update_native_stats(_output, _timestamp, _was_prefilling, native_stats, *_args):
|
||||
native_stats.num_generation_tokens = 27
|
||||
native_stats.first_token_ts = 10.0
|
||||
native_stats.last_token_ts = 10.26
|
||||
|
||||
iteration_stats.update_from_output.side_effect = update_native_stats
|
||||
|
||||
processor._update_stats_from_output(
|
||||
state,
|
||||
SimpleNamespace(),
|
||||
10.26,
|
||||
iteration_stats,
|
||||
)
|
||||
|
||||
assert processor.pop_native_text_metrics("r")["num_generation_tokens"] == 27
|
||||
|
||||
|
||||
def test_delta_drains_output_modality_per_step():
|
||||
"""DELTA drains the mm_type key (output modality) but preserves hidden-state keys."""
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
@@ -172,6 +234,145 @@ def test_finish_consolidation_drains_mm_delta():
|
||||
assert AUDIO not in s.mm_accumulated # drained
|
||||
|
||||
|
||||
def test_delta_drains_audio_chunk_metadata_per_step():
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
segment_text = "你好"
|
||||
segment_utf8 = torch.tensor(list(segment_text.encode("utf-8")), dtype=torch.uint8)
|
||||
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
"model_outputs": torch.ones(5),
|
||||
"meta.duplex_epoch": torch.tensor([7], dtype=torch.int32),
|
||||
"meta.duplex_turn_id": torch.tensor([1], dtype=torch.int32),
|
||||
"meta.llm_output_text_utf8": segment_utf8,
|
||||
"meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32),
|
||||
"meta.tts_is_last_chunk": torch.tensor([0], dtype=torch.int32),
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
result1 = s.make_request_output([1], None, FinishReason.STOP, None)
|
||||
assert result1 is not None and not isinstance(result1, PoolingRequestOutput)
|
||||
meta1 = result1.outputs[0].multimodal_output["meta"]
|
||||
assert bytes(meta1["llm_output_text_utf8"].tolist()).decode("utf-8") == segment_text
|
||||
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
"model_outputs": torch.ones(3),
|
||||
"meta.duplex_epoch": torch.tensor([8], dtype=torch.int32),
|
||||
"meta.duplex_turn_id": torch.tensor([2], dtype=torch.int32),
|
||||
"meta.llm_output_text_utf8": segment_utf8,
|
||||
"meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32),
|
||||
"meta.tts_is_last_chunk": torch.tensor([1], dtype=torch.int32),
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
result2 = s.make_request_output([2], None, FinishReason.STOP, None)
|
||||
|
||||
assert result2 is not None and not isinstance(result2, PoolingRequestOutput)
|
||||
meta2 = result2.outputs[0].multimodal_output["meta"]
|
||||
assert bytes(meta2["llm_output_text_utf8"].tolist()).decode("utf-8") == segment_text
|
||||
assert meta2["audio_text_total_chars"].tolist() == [len(segment_text)]
|
||||
assert meta2["duplex_epoch"].tolist() == [8]
|
||||
assert meta2["duplex_turn_id"].tolist() == [2]
|
||||
|
||||
|
||||
def test_cumulative_audio_replaces_chunk_metadata_per_step():
|
||||
s = _make_state(RequestOutputKind.CUMULATIVE)
|
||||
segment_text = "你好"
|
||||
segment_utf8 = torch.tensor(list(segment_text.encode("utf-8")), dtype=torch.uint8)
|
||||
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
"model_outputs": torch.ones(5),
|
||||
"meta.duplex_epoch": torch.tensor([7], dtype=torch.int32),
|
||||
"meta.duplex_turn_id": torch.tensor([1], dtype=torch.int32),
|
||||
"meta.llm_output_text_utf8": segment_utf8,
|
||||
"meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32),
|
||||
"meta.tts_is_last_chunk": torch.tensor([0], dtype=torch.int32),
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
result1 = s.make_request_output([1], None, None, None)
|
||||
assert result1 is not None and not isinstance(result1, PoolingRequestOutput)
|
||||
assert result1.outputs[0].multimodal_output["audio"].shape[0] == 5
|
||||
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
"model_outputs": torch.ones(3),
|
||||
"meta.duplex_epoch": torch.tensor([8], dtype=torch.int32),
|
||||
"meta.duplex_turn_id": torch.tensor([2], dtype=torch.int32),
|
||||
"meta.llm_output_text_utf8": segment_utf8,
|
||||
"meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32),
|
||||
"meta.tts_is_last_chunk": torch.tensor([1], dtype=torch.int32),
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
result2 = s.make_request_output([2], None, None, None)
|
||||
|
||||
assert result2 is not None and not isinstance(result2, PoolingRequestOutput)
|
||||
output = result2.outputs[0].multimodal_output
|
||||
assert output["audio"].shape[0] == 8
|
||||
meta = output["meta"]
|
||||
assert bytes(meta["llm_output_text_utf8"].tolist()).decode("utf-8") == segment_text
|
||||
assert meta["audio_text_total_chars"].tolist() == [len(segment_text)]
|
||||
assert meta["duplex_epoch"].tolist() == [8]
|
||||
assert meta["duplex_turn_id"].tolist() == [2]
|
||||
assert meta["tts_is_last_chunk"].tolist() == [1]
|
||||
|
||||
|
||||
def test_delta_audio_non_final_tts_chunk_overrides_spurious_finish():
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
"model_outputs": torch.ones(5),
|
||||
"meta.tts_is_last_chunk": torch.tensor([0]),
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
|
||||
result = s.make_request_output([1], None, FinishReason.STOP, None)
|
||||
|
||||
assert result is not None and not isinstance(result, PoolingRequestOutput)
|
||||
assert result.finished is False
|
||||
assert result.outputs[0].finish_reason is None
|
||||
assert result.outputs[0].multimodal_output["meta"]["tts_is_last_chunk"].tolist() == [0]
|
||||
assert torch.equal(result.outputs[0].multimodal_output[AUDIO], torch.ones(5))
|
||||
|
||||
|
||||
def test_delta_audio_final_tts_chunk_keeps_finish():
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
"model_outputs": torch.ones(5),
|
||||
"meta.tts_is_last_chunk": torch.tensor([1]),
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
|
||||
result = s.make_request_output([151645], None, FinishReason.STOP, None)
|
||||
|
||||
assert result is not None and not isinstance(result, PoolingRequestOutput)
|
||||
assert result.finished is True
|
||||
assert result.outputs[0].finish_reason == "stop"
|
||||
|
||||
|
||||
def test_delta_audio_non_final_tts_chunk_accepts_nested_meta():
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
s.add_multimodal_tensor(
|
||||
{
|
||||
AUDIO: torch.ones(5),
|
||||
"meta": {"tts_is_last_chunk": torch.tensor([0])},
|
||||
},
|
||||
mm_type=AUDIO,
|
||||
)
|
||||
|
||||
result = s.make_request_output([1], None, FinishReason.STOP, None)
|
||||
|
||||
assert result is not None and not isinstance(result, PoolingRequestOutput)
|
||||
assert result.finished is False
|
||||
assert result.outputs[0].multimodal_output["meta"]["tts_is_last_chunk"].tolist() == [0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mm_type", [AUDIO, "hidden"])
|
||||
def test_final_only_consolidates_drainable_keys(mm_type):
|
||||
"""FINAL_ONLY never drains per-step, so modality keys and hidden state
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import queue
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.engine.messages import (
|
||||
CollectiveRPCResultMessage,
|
||||
EngineQueueMessage,
|
||||
ErrorMessage,
|
||||
)
|
||||
from vllm_omni.engine.rpc_result_router import RpcResultRouter
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class GenericCorrelatedResultMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["generic_correlated_result"] = "generic_correlated_result"
|
||||
namespace: str
|
||||
correlation_id: str
|
||||
|
||||
@property
|
||||
def rpc_correlation_key(self) -> tuple[str, str]:
|
||||
return (self.namespace, self.correlation_id)
|
||||
|
||||
|
||||
def _generic_result(correlation_id: str) -> GenericCorrelatedResultMessage:
|
||||
return GenericCorrelatedResultMessage(
|
||||
namespace="plugin",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
|
||||
def test_rpc_result_router_routes_out_of_order_results_by_correlation_id():
|
||||
source: queue.Queue = queue.Queue()
|
||||
router = RpcResultRouter(source)
|
||||
first = router.register(("plugin", "first"))
|
||||
second = router.register(("plugin", "second"))
|
||||
|
||||
source.put(_generic_result("second"))
|
||||
source.put(_generic_result("first"))
|
||||
|
||||
assert first.get(timeout=1).correlation_id == "first"
|
||||
assert second.get(timeout=1).correlation_id == "second"
|
||||
router.close()
|
||||
|
||||
|
||||
def test_rpc_result_router_drops_only_the_late_result_after_unregister():
|
||||
source: queue.Queue = queue.Queue()
|
||||
router = RpcResultRouter(source)
|
||||
expired = router.register(("plugin", "expired"))
|
||||
active = router.register(("collective", "active"))
|
||||
router.unregister(("plugin", "expired"), expired)
|
||||
|
||||
source.put(_generic_result("expired"))
|
||||
source.put(
|
||||
CollectiveRPCResultMessage(
|
||||
rpc_id="active",
|
||||
method="health",
|
||||
stage_ids=[0],
|
||||
results=["ok"],
|
||||
)
|
||||
)
|
||||
|
||||
assert active.get(timeout=1).rpc_id == "active"
|
||||
assert expired.empty()
|
||||
router.close()
|
||||
|
||||
|
||||
def test_rpc_result_router_broadcasts_fatal_errors_to_pending_waiters():
|
||||
source: queue.Queue = queue.Queue()
|
||||
router = RpcResultRouter(source)
|
||||
plugin = router.register(("plugin", "one"))
|
||||
collective = router.register(("collective", "two"))
|
||||
|
||||
source.put(ErrorMessage(error="orchestrator failed", fatal=True))
|
||||
|
||||
assert plugin.get(timeout=1).error == "orchestrator failed"
|
||||
assert collective.get(timeout=1).error == "orchestrator failed"
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
router.register(("plugin", "after-failure"))
|
||||
router.close()
|
||||
|
||||
|
||||
def test_rpc_result_router_does_not_broadcast_uncorrelated_nonfatal_errors():
|
||||
source: queue.Queue = queue.Queue()
|
||||
router = RpcResultRouter(source)
|
||||
waiter = router.register(("plugin", "active"))
|
||||
|
||||
source.put(ErrorMessage(error="request failed", fatal=False, request_id="other"))
|
||||
source.put(_generic_result("active"))
|
||||
|
||||
assert waiter.get(timeout=1).correlation_id == "active"
|
||||
router.close()
|
||||
|
||||
|
||||
def test_rpc_result_router_close_unblocks_waiters_and_stops_consumer():
|
||||
source: queue.Queue = queue.Queue()
|
||||
router = RpcResultRouter(source)
|
||||
waiter = router.register(("plugin", "pending"))
|
||||
|
||||
router.close()
|
||||
|
||||
result = waiter.get(timeout=1)
|
||||
assert isinstance(result, ErrorMessage)
|
||||
assert result.fatal is True
|
||||
assert result.error == "RPC result router closed"
|
||||
assert not router._thread.is_alive()
|
||||
with pytest.raises(RuntimeError, match="router is closed"):
|
||||
router.register(("plugin", "after-close"))
|
||||
@@ -0,0 +1,474 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.protocol import (
|
||||
DuplexCapabilities,
|
||||
DuplexOverlapPolicy,
|
||||
DuplexSession,
|
||||
DuplexSessionConfig,
|
||||
DuplexSessionRegistry,
|
||||
DuplexTurnController,
|
||||
DuplexTurnEventType,
|
||||
DuplexTurnState,
|
||||
ResponseCreateOptions,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.openai.realtime_session import (
|
||||
NativeRealtimeSessionProtocol,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.openai.realtime_state import RealtimeStateOwner
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_realtime_state_owner_uses_explicit_bindings_not_dynamic_attribute_proxy():
|
||||
assert "__getattr__" not in RealtimeStateOwner.__dict__
|
||||
assert "__setattr__" not in RealtimeStateOwner.__dict__
|
||||
|
||||
|
||||
class _ProtocolWebSocket:
|
||||
def __init__(self, *events: dict[str, object]) -> None:
|
||||
self._events = asyncio.Queue()
|
||||
for event in events:
|
||||
self._events.put_nowait(json.dumps(event))
|
||||
|
||||
async def receive_text(self) -> str:
|
||||
return await self._events.get()
|
||||
|
||||
|
||||
def test_duplex_session_commits_text_and_audio_as_one_turn():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create(DuplexSessionConfig(model="test-model"))
|
||||
|
||||
session.append_text("hello")
|
||||
session.append_audio("YWJj", fmt="wav", sample_rate_hz=16000)
|
||||
committed = session.commit_user_input()
|
||||
|
||||
assert committed is not None
|
||||
assert committed.turn_id == 0
|
||||
assert committed.input_commit_seq == 1
|
||||
assert committed.epoch == 0
|
||||
assert len(session.history) == 1
|
||||
content = session.history[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[0] == {"type": "text", "text": "hello"}
|
||||
assert content[1]["type"] == "audio_url"
|
||||
assert content[1]["audio_url"]["url"] == "data:audio/wav;base64,YWJj"
|
||||
assert session.turn_state == DuplexTurnState.USER_COMMITTED
|
||||
|
||||
|
||||
def test_response_options_apply_to_one_response_without_mutating_session_defaults():
|
||||
session = DuplexSession(
|
||||
session_id="sid-response-options",
|
||||
config=DuplexSessionConfig(instructions="base", voice="base-voice", max_tokens=64),
|
||||
)
|
||||
session.reserve_response_options(
|
||||
ResponseCreateOptions(
|
||||
instructions="one response",
|
||||
voice="override-voice",
|
||||
max_tokens=8,
|
||||
)
|
||||
)
|
||||
|
||||
assert session.config.instructions == "base"
|
||||
assert session.config.voice == "base-voice"
|
||||
session.begin_response()
|
||||
assert session.config.instructions == "base"
|
||||
assert session.config.voice == "base-voice"
|
||||
assert session.response_config.instructions == "one response"
|
||||
assert session.response_config.voice == "override-voice"
|
||||
assert session.response_config.max_tokens == 8
|
||||
|
||||
session.end_response()
|
||||
assert session.config.instructions == "base"
|
||||
assert session.config.voice == "base-voice"
|
||||
assert session.config.max_tokens == 64
|
||||
|
||||
|
||||
def test_response_options_cannot_overwrite_an_unconsumed_reservation():
|
||||
session = DuplexSession(
|
||||
session_id="sid-response-options-pending",
|
||||
config=DuplexSessionConfig(instructions="base"),
|
||||
)
|
||||
session.reserve_response_options(ResponseCreateOptions(instructions="first"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="already reserved"):
|
||||
session.reserve_response_options(ResponseCreateOptions(instructions="second"))
|
||||
|
||||
session.begin_response()
|
||||
assert session.response_config.instructions == "first"
|
||||
|
||||
|
||||
def test_response_options_cannot_be_reserved_while_response_is_active():
|
||||
session = DuplexSession(
|
||||
session_id="sid-response-options-active",
|
||||
config=DuplexSessionConfig(instructions="base"),
|
||||
)
|
||||
session.begin_response()
|
||||
|
||||
with pytest.raises(RuntimeError, match="active"):
|
||||
session.reserve_response_options(ResponseCreateOptions(instructions="too late"))
|
||||
|
||||
|
||||
def test_realtime_model_name_does_not_implicitly_enable_native_duplex():
|
||||
protocol = NativeRealtimeSessionProtocol({})
|
||||
|
||||
event = protocol._session_create_from_realtime({"model": "openbmb/MiniCPM-o-4_5"})
|
||||
|
||||
assert "minicpmo45_native_duplex" not in event["session"]["extra_body"]
|
||||
|
||||
|
||||
def test_realtime_explicit_native_duplex_flag_is_preserved():
|
||||
protocol = NativeRealtimeSessionProtocol({})
|
||||
|
||||
event = protocol._session_create_from_realtime(
|
||||
{
|
||||
"model": "openbmb/MiniCPM-o-4_5",
|
||||
"extra_body": {"minicpmo45_native_duplex": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert event["session"]["extra_body"]["minicpmo45_native_duplex"] is True
|
||||
|
||||
|
||||
def test_realtime_explicit_query_native_duplex_flag_is_available_before_autostart():
|
||||
protocol = NativeRealtimeSessionProtocol(
|
||||
{
|
||||
"model": "openbmb/MiniCPM-o-4_5",
|
||||
"minicpmo45_native_duplex": "1",
|
||||
}
|
||||
)
|
||||
|
||||
event = json.loads(asyncio.run(protocol.receive_internal_event_text(None)))
|
||||
|
||||
assert event["type"] == "session.create"
|
||||
assert event["session"]["extra_body"]["minicpmo45_native_duplex"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_resume_heartbeat_and_event_ack_translate_without_session_autostart():
|
||||
protocol = NativeRealtimeSessionProtocol(
|
||||
{
|
||||
"model": "openbmb/MiniCPM-o-4_5",
|
||||
"minicpmo45_native_duplex": "1",
|
||||
}
|
||||
)
|
||||
|
||||
resume = await protocol._to_duplex_event(
|
||||
{
|
||||
"type": "session.resume",
|
||||
"session_id": "sid-resume",
|
||||
"incarnation": 3,
|
||||
"resume_token": "opaque-token",
|
||||
"last_received_server_event_seq": 17,
|
||||
}
|
||||
)
|
||||
heartbeat = await protocol._to_duplex_event(
|
||||
{
|
||||
"type": "session.heartbeat",
|
||||
"event_id": "event-heartbeat",
|
||||
}
|
||||
)
|
||||
event_ack = await protocol._to_duplex_event(
|
||||
{
|
||||
"type": "session.event_ack",
|
||||
"server_event_seq": 19,
|
||||
}
|
||||
)
|
||||
|
||||
assert resume == {
|
||||
"type": "session.resume",
|
||||
"session_id": "sid-resume",
|
||||
"incarnation": 3,
|
||||
"resume_token": "opaque-token",
|
||||
"last_received_server_event_seq": 17,
|
||||
}
|
||||
assert heartbeat == {
|
||||
"type": "session.heartbeat",
|
||||
"event_id": "event-heartbeat",
|
||||
}
|
||||
assert event_ack == {
|
||||
"type": "session.event_ack",
|
||||
"server_event_seq": 19,
|
||||
}
|
||||
assert protocol._opened is False
|
||||
|
||||
wire_protocol = NativeRealtimeSessionProtocol({})
|
||||
first_wire_event = json.loads(
|
||||
await wire_protocol.receive_internal_event_text(
|
||||
_ProtocolWebSocket(
|
||||
{
|
||||
"type": "session.resume",
|
||||
"session_id": "sid-resume",
|
||||
"incarnation": 3,
|
||||
"resume_token": "opaque-token",
|
||||
"last_received_server_event_seq": 17,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert first_wire_event["type"] == "session.resume"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_resume_query_suppresses_model_autostart_on_same_url():
|
||||
protocol = NativeRealtimeSessionProtocol(
|
||||
{
|
||||
"model": "openbmb/MiniCPM-o-4_5",
|
||||
"minicpmo45_native_duplex": "1",
|
||||
"resume": "1",
|
||||
}
|
||||
)
|
||||
resume = {
|
||||
"type": "session.resume",
|
||||
"session_id": "sid-resume",
|
||||
"incarnation": 3,
|
||||
"resume_token": "opaque-token",
|
||||
"last_received_server_event_seq": 17,
|
||||
}
|
||||
|
||||
first_wire_event = json.loads(await protocol.receive_internal_event_text(_ProtocolWebSocket(resume)))
|
||||
|
||||
assert first_wire_event == resume
|
||||
assert protocol._autostarted_default_session is False
|
||||
|
||||
|
||||
def test_realtime_projects_resume_lifecycle_events_without_duplex_prefix():
|
||||
protocol = NativeRealtimeSessionProtocol({})
|
||||
|
||||
created = protocol.encode_outbound_event(
|
||||
{
|
||||
"type": "session.created",
|
||||
"session": {"id": "sid-resume"},
|
||||
"incarnation": 3,
|
||||
"attachment_generation": 1,
|
||||
"resume_token": "first-token",
|
||||
}
|
||||
)[0]
|
||||
resumed = protocol.encode_outbound_event(
|
||||
{
|
||||
"type": "session.resumed",
|
||||
"session_id": "sid-resume",
|
||||
"incarnation": 3,
|
||||
"attachment_generation": 2,
|
||||
"resume_token": "second-token",
|
||||
}
|
||||
)[0]
|
||||
heartbeat = protocol.encode_outbound_event(
|
||||
{
|
||||
"type": "session.heartbeat_ack",
|
||||
"session_id": "sid-resume",
|
||||
}
|
||||
)[0]
|
||||
replaced = protocol.encode_outbound_event(
|
||||
{
|
||||
"type": "session.replaced",
|
||||
"session_id": "sid-resume",
|
||||
"attachment_generation": 1,
|
||||
}
|
||||
)[0]
|
||||
resync = protocol.encode_outbound_event(
|
||||
{
|
||||
"type": "session.resync_required",
|
||||
"session_id": "sid-resume",
|
||||
"reason": "journal_gap",
|
||||
}
|
||||
)[0]
|
||||
|
||||
assert created["type"] == "session.created"
|
||||
assert created["incarnation"] == 3
|
||||
assert created["attachment_generation"] == 1
|
||||
assert created["resume_token"] == "first-token"
|
||||
assert resumed["type"] == "session.resumed"
|
||||
assert resumed["resume_token"] == "second-token"
|
||||
assert heartbeat["type"] == "session.heartbeat_ack"
|
||||
assert replaced["type"] == "session.replaced"
|
||||
assert resync == {
|
||||
"type": "session.resync_required",
|
||||
"session_id": "sid-resume",
|
||||
"reason": "journal_gap",
|
||||
"event_id": resync["event_id"],
|
||||
}
|
||||
|
||||
|
||||
def test_duplex_session_registry_advances_incarnation_when_id_is_reused():
|
||||
registry = DuplexSessionRegistry()
|
||||
|
||||
first = registry.create(session_id="reused-session")
|
||||
registry.close(first.session_id)
|
||||
second = registry.create(session_id="reused-session")
|
||||
|
||||
assert first.incarnation == 0
|
||||
assert second.incarnation == 1
|
||||
|
||||
|
||||
def test_native_input_commit_does_not_advance_model_turn_identity():
|
||||
session = DuplexSession(
|
||||
session_id="sid-model-owned-turn",
|
||||
config=DuplexSessionConfig(extra_body={"auto_response": True}),
|
||||
)
|
||||
|
||||
first = session.commit_native_audio_input(transcript="first chunk")
|
||||
second = session.commit_native_audio_input(transcript="second chunk")
|
||||
|
||||
assert session.input_commit_seq == 2
|
||||
assert first.input_commit_seq == 1
|
||||
assert second.input_commit_seq == 2
|
||||
assert first.turn_id == second.turn_id == session.turn_id == 0
|
||||
|
||||
|
||||
def test_duplex_session_owns_response_and_overlap_identity():
|
||||
session = DuplexSession(session_id="sid-state-owner", config=DuplexSessionConfig())
|
||||
|
||||
response_id = session.begin_response()
|
||||
session.accumulate_overlap_speech(320)
|
||||
session.accumulate_overlap_speech(180)
|
||||
session.end_response()
|
||||
|
||||
assert session.active_response_id is None
|
||||
assert session.last_response_id == response_id
|
||||
assert session.overlap_speech_ms == 500
|
||||
assert session.reset_overlap_speech() == 500
|
||||
assert session.overlap_speech_ms == 0
|
||||
|
||||
|
||||
def test_duplex_session_composes_single_owner_ledgers():
|
||||
session = DuplexSession(session_id="sid-ledgers", config=DuplexSessionConfig())
|
||||
|
||||
session.append_text("hello")
|
||||
session.bind_request("req-1")
|
||||
response_id = session.begin_response(turn_id=3)
|
||||
session.mark_audio_sent(duration_ms=240)
|
||||
|
||||
assert not hasattr(session, "input")
|
||||
assert not hasattr(session, "response")
|
||||
assert not hasattr(session, "playback_ledger")
|
||||
assert not hasattr(session, "conversation")
|
||||
assert session.pending_text == ("hello",)
|
||||
assert session.active_request_id == "req-1"
|
||||
assert session.active_response_id == response_id
|
||||
assert session.active_response_turn_id == 3
|
||||
assert session.playback.sent_ms == 240
|
||||
|
||||
try:
|
||||
session.playback.sent_ms = 480
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("playback view must be immutable")
|
||||
|
||||
|
||||
def test_duplex_barge_in_advances_epoch_and_drops_uncommitted_assistant_text():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create()
|
||||
response_id = session.begin_response()
|
||||
session.bind_request("chatcmpl-duplex-test")
|
||||
session.append_assistant_text("unplayed answer")
|
||||
|
||||
new_epoch = session.barge_in()
|
||||
|
||||
assert response_id is not None
|
||||
assert new_epoch == 1
|
||||
assert session.epoch == 1
|
||||
assert session.active_request_id is None
|
||||
assert session.active_response_id is None
|
||||
assert session.assistant_text_buffer == ()
|
||||
assert session.history == ()
|
||||
assert session.turn_state == DuplexTurnState.BARGE_IN
|
||||
|
||||
|
||||
def test_duplex_playback_ack_tracks_committed_cursor_separately():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create()
|
||||
session.mark_audio_sent(duration_ms=10_000)
|
||||
|
||||
session.acknowledge_playback(played_ms=2_000)
|
||||
|
||||
assert session.playback.generated_ms == 10_000
|
||||
assert session.playback.sent_ms == 10_000
|
||||
assert session.playback.played_ms == 2_000
|
||||
assert session.playback.committed_ms == 2_000
|
||||
|
||||
|
||||
def test_duplex_history_commit_uses_audio_text_alignment_marks():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create()
|
||||
session.begin_response()
|
||||
|
||||
session.append_assistant_text("hello ")
|
||||
session.mark_audio_sent(duration_ms=1_000, text_chars=6)
|
||||
session.append_assistant_text("world")
|
||||
session.mark_audio_sent(duration_ms=2_000, text_chars=11)
|
||||
session.acknowledge_playback(played_ms=1_200, committed_ms=1_200)
|
||||
|
||||
committed = session.end_response(commit_text=True)
|
||||
|
||||
assert committed == {"role": "assistant", "content": "hello w"}
|
||||
|
||||
|
||||
def test_turn_controller_accepts_external_signal_source():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create()
|
||||
controller = DuplexTurnController()
|
||||
|
||||
event = controller.signal(session, DuplexTurnEventType.USER_STARTED.value)
|
||||
|
||||
assert event["type"] == "turn.event"
|
||||
assert event["event"] == "user_started"
|
||||
assert event["turn_state"] == "user_speaking"
|
||||
assert session.turn_state == DuplexTurnState.USER_SPEAKING
|
||||
|
||||
|
||||
def test_duplex_capabilities_do_not_claim_core_kv_or_input_append():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create()
|
||||
caps = session.capabilities.as_dict()
|
||||
|
||||
assert caps["implementation_level"] == "serving_session_adapter"
|
||||
assert caps["supports_kv_lease"] is False
|
||||
assert caps["supports_input_append"] is False
|
||||
assert caps["supports_reencode_context"] is True
|
||||
assert caps["adapter_patterns"] == ["chunk_group_append"]
|
||||
assert "turn_commit_only" in caps["input_modes"]
|
||||
assert "client_event" in caps["signal_sources"]
|
||||
|
||||
|
||||
def test_minicpmo_native_capabilities_separate_model_state_from_core_kv_lease():
|
||||
caps = DuplexCapabilities.minicpmo45_native(max_sessions=2).as_dict()
|
||||
|
||||
assert caps["implementation_level"] == "model_native_duplex"
|
||||
assert caps["supports_input_append"] is True
|
||||
assert caps["input_modes"] == ["append_audio_chunk"]
|
||||
assert caps["adapter_patterns"] == ["scheduler_data_plane"]
|
||||
assert caps["supports_model_internal_state"] is True
|
||||
assert caps["requires_model_runner_kv"] is True
|
||||
assert caps["requires_native_stage_role"] is True
|
||||
assert caps["supports_kv_lease"] is False
|
||||
assert caps["supports_core_kv_lease"] is False
|
||||
assert caps["supports_stage_resumption"] is True
|
||||
assert caps["supports_scheduler_native_append"] is False
|
||||
assert caps["supports_core_resumable_request"] is True
|
||||
assert caps["supports_stage_connector_handoff"] is True
|
||||
assert caps["supports_audio_truncate"] is True
|
||||
assert caps["supports_barge_in"] is False
|
||||
assert caps["target_barge_in_latency_ms"] is None
|
||||
assert caps["supports_multi_session"] is True
|
||||
assert caps["supports_multi_session_same_replica"] is True
|
||||
assert caps["supports_session_lease"] is True
|
||||
assert caps["supports_session_resume"] is True
|
||||
assert caps["session_admission_mode"] == "engine_managed"
|
||||
assert caps["stage_handoff_transport"] == "scheduler_data_plane"
|
||||
|
||||
|
||||
def test_minicpmo_native_capabilities_do_not_overclaim_single_session_deployment():
|
||||
caps = DuplexCapabilities.minicpmo45_native(max_sessions=1).as_dict()
|
||||
|
||||
assert caps["supports_multi_session"] is False
|
||||
assert caps["supports_multi_session_same_replica"] is False
|
||||
|
||||
|
||||
def test_duplex_overlap_policy_defaults_and_invalid_values_to_listen_only():
|
||||
assert DuplexSessionConfig().overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
|
||||
assert DuplexSessionConfig._normalize_overlap_policy("not-a-policy") == DuplexOverlapPolicy.LISTEN_ONLY.value
|
||||
@@ -0,0 +1,387 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.session_attachment import (
|
||||
DuplexEventJournal,
|
||||
DuplexJournalGapError,
|
||||
DuplexJournalOverflowError,
|
||||
DuplexResumeCredential,
|
||||
DuplexSessionAttachmentRegistry,
|
||||
InvalidResumeTokenError,
|
||||
ResumeToken,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.value += seconds
|
||||
|
||||
|
||||
def _decoded_token_bytes(token: ResumeToken) -> bytes:
|
||||
padding = "=" * (-len(token.plaintext) % 4)
|
||||
return base64.urlsafe_b64decode(token.plaintext + padding)
|
||||
|
||||
|
||||
def test_resume_credential_uses_256_bit_token_digest_and_redacted_repr(monkeypatch) -> None:
|
||||
token = ResumeToken.generate()
|
||||
credential = DuplexResumeCredential.from_token(token)
|
||||
calls = []
|
||||
|
||||
import vllm_omni.experimental.fullduplex.openai.session_attachment as attachment_module
|
||||
|
||||
original_compare = attachment_module.hmac.compare_digest
|
||||
|
||||
def recording_compare(left, right):
|
||||
calls.append((left, right))
|
||||
return original_compare(left, right)
|
||||
|
||||
monkeypatch.setattr(attachment_module.hmac, "compare_digest", recording_compare)
|
||||
|
||||
assert len(_decoded_token_bytes(token)) == 32
|
||||
assert credential.verify(token.plaintext) is True
|
||||
assert credential.verify("invalid-token") is False
|
||||
assert len(calls) == 2
|
||||
assert token.plaintext not in repr(token)
|
||||
assert token.plaintext not in repr(credential)
|
||||
assert not hasattr(credential, "plaintext")
|
||||
assert len(credential.token_digest) == 32
|
||||
|
||||
|
||||
def test_resume_credential_rotation_revokes_old_token() -> None:
|
||||
first = ResumeToken.generate()
|
||||
credential = DuplexResumeCredential.from_token(first)
|
||||
|
||||
second = credential.rotate()
|
||||
|
||||
assert credential.verify(first.plaintext) is False
|
||||
assert credential.verify(second.plaintext) is True
|
||||
assert second.plaintext != first.plaintext
|
||||
|
||||
|
||||
def test_event_journal_sequences_acknowledges_and_replays_exact_payloads() -> None:
|
||||
clock = _Clock()
|
||||
journal = DuplexEventJournal(max_bytes=4096, ttl_s=60.0, clock=clock)
|
||||
|
||||
first = journal.record({"type": "response.audio.delta", "delta": "AAAA"})
|
||||
second = journal.record({"type": "response.done", "response_id": "resp-1"})
|
||||
|
||||
assert first.sequence == 1
|
||||
assert second.sequence == 2
|
||||
assert first.payload["server_event_seq"] == 1
|
||||
assert second.payload["server_event_seq"] == 2
|
||||
assert first.encoded_bytes == len(
|
||||
json.dumps(dict(first.payload), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
)
|
||||
assert [entry.sequence for entry in journal.replay_after(0)] == [1, 2]
|
||||
assert journal.acknowledge(1) == 1
|
||||
assert [entry.sequence for entry in journal.replay_after(1)] == [2]
|
||||
assert journal.acknowledge(1) == 0
|
||||
|
||||
|
||||
def test_event_journal_ttl_pruning_reports_replay_gap() -> None:
|
||||
clock = _Clock()
|
||||
journal = DuplexEventJournal(max_bytes=4096, ttl_s=5.0, clock=clock)
|
||||
journal.record({"type": "one"})
|
||||
clock.advance(3.0)
|
||||
journal.record({"type": "two"})
|
||||
clock.advance(3.0)
|
||||
|
||||
assert journal.prune() == 1
|
||||
with pytest.raises(DuplexJournalGapError, match="older than retained"):
|
||||
journal.replay_after(0)
|
||||
assert [entry.payload["type"] for entry in journal.replay_after(1)] == ["two"]
|
||||
|
||||
|
||||
def test_event_journal_overflow_is_explicit_and_does_not_evict_silently() -> None:
|
||||
clock = _Clock()
|
||||
journal = DuplexEventJournal(max_bytes=100, ttl_s=60.0, clock=clock)
|
||||
first = journal.record({"type": "small", "value": "x"})
|
||||
|
||||
with pytest.raises(DuplexJournalOverflowError, match="byte limit"):
|
||||
journal.record({"type": "large", "value": "x" * 200})
|
||||
|
||||
assert journal.overflowed is True
|
||||
assert journal.retained_bytes == first.encoded_bytes
|
||||
with pytest.raises(DuplexJournalGapError, match="overflowed"):
|
||||
journal.replay_after(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_resume_rotates_token_replays_and_atomically_replaces_attachment() -> None:
|
||||
clock = _Clock()
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
clock=clock,
|
||||
)
|
||||
sends_a = []
|
||||
closes_a = []
|
||||
sends_b = []
|
||||
closes_b = []
|
||||
|
||||
async def send_a(payload):
|
||||
sends_a.append(payload)
|
||||
|
||||
async def close_a(reason):
|
||||
closes_a.append(reason)
|
||||
|
||||
async def send_b(payload):
|
||||
sends_b.append(payload)
|
||||
|
||||
async def close_b(reason):
|
||||
closes_b.append(reason)
|
||||
|
||||
created = await registry.create("sid", incarnation=2, send=send_a, close=close_a)
|
||||
await registry.send_event("sid", {"type": "event-1"})
|
||||
await registry.send_event("sid", {"type": "event-2"})
|
||||
|
||||
resumed = await registry.resume(
|
||||
"sid",
|
||||
incarnation=2,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=1,
|
||||
send=send_b,
|
||||
close=close_b,
|
||||
)
|
||||
|
||||
assert resumed.attachment_generation == 2
|
||||
assert resumed.resume_token.plaintext != created.resume_token.plaintext
|
||||
assert [entry.sequence for entry in resumed.replay_entries] == [2]
|
||||
assert resumed.replaced_attachment is not None
|
||||
assert resumed.replaced_attachment.generation == 1
|
||||
assert await registry.is_current_attachment("sid", created.attachment_generation) is False
|
||||
assert await registry.is_current_attachment("sid", resumed.attachment_generation) is True
|
||||
assert await registry.detach("sid", attachment_generation=1) is False
|
||||
assert await registry.detach("sid", attachment_generation=2) is True
|
||||
assert [(payload["type"], payload["server_event_seq"]) for payload in sends_a] == [
|
||||
("event-1", 1),
|
||||
("event-2", 2),
|
||||
]
|
||||
assert closes_a == [] and sends_b == [] and closes_b == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_resume_sends_activation_then_replay_before_new_live_events() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
)
|
||||
wire = []
|
||||
activation_started = asyncio.Event()
|
||||
release_activation = asyncio.Event()
|
||||
|
||||
async def old_send(payload):
|
||||
del payload
|
||||
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
async def new_send(payload):
|
||||
wire.append(payload["type"])
|
||||
if payload["type"] == "session.resumed":
|
||||
activation_started.set()
|
||||
await release_activation.wait()
|
||||
|
||||
created = await registry.create("sid-order", incarnation=0, send=old_send, close=close)
|
||||
await registry.detach("sid-order", attachment_generation=1)
|
||||
await registry.send_event("sid-order", {"type": "replayed"})
|
||||
resume_task = asyncio.create_task(
|
||||
registry.resume(
|
||||
"sid-order",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=new_send,
|
||||
close=close,
|
||||
activation_payload_factory=lambda _token, _generation: {"type": "session.resumed"},
|
||||
)
|
||||
)
|
||||
await activation_started.wait()
|
||||
live_task = asyncio.create_task(registry.send_event("sid-order", {"type": "live"}))
|
||||
|
||||
release_activation.set()
|
||||
await asyncio.gather(resume_task, live_task)
|
||||
|
||||
assert wire == ["session.resumed", "replayed", "live"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recovery() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
)
|
||||
|
||||
async def send(payload):
|
||||
del payload
|
||||
|
||||
async def failing_send(payload):
|
||||
del payload
|
||||
raise RuntimeError("transport lost before rotated token arrived")
|
||||
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-recovery", incarnation=0, send=send, close=close)
|
||||
await registry.detach("sid-recovery", attachment_generation=1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="transport lost"):
|
||||
await registry.resume(
|
||||
"sid-recovery",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=failing_send,
|
||||
close=close,
|
||||
activation_payload_factory=lambda token, generation: {
|
||||
"type": "session.resumed",
|
||||
"resume_token": token.plaintext,
|
||||
"attachment_generation": generation,
|
||||
},
|
||||
)
|
||||
|
||||
recovered = await registry.resume(
|
||||
"sid-recovery",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
|
||||
assert recovered.attachment_generation == 3
|
||||
with pytest.raises(InvalidResumeTokenError):
|
||||
await registry.resume(
|
||||
"sid-recovery",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winner() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
)
|
||||
|
||||
async def send(payload):
|
||||
del payload
|
||||
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-race", incarnation=0, send=send, close=close)
|
||||
|
||||
async def attempt():
|
||||
try:
|
||||
return await registry.resume(
|
||||
"sid-race",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
except InvalidResumeTokenError as exc:
|
||||
return exc
|
||||
|
||||
results = await asyncio.gather(attempt(), attempt())
|
||||
|
||||
assert sum(not isinstance(result, Exception) for result in results) == 1
|
||||
assert sum(isinstance(result, InvalidResumeTokenError) for result in results) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
)
|
||||
|
||||
async def send(payload):
|
||||
del payload
|
||||
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created_a = await registry.create("sid-a", incarnation=1, send=send, close=close)
|
||||
created_b = await registry.create("sid-b", incarnation=4, send=send, close=close)
|
||||
await registry.detach("sid-a", attachment_generation=1)
|
||||
await registry.detach("sid-b", attachment_generation=1)
|
||||
await registry.send_event("sid-a", {"type": "a-only"})
|
||||
await registry.send_event("sid-b", {"type": "b-only"})
|
||||
|
||||
resumed_a = await registry.resume(
|
||||
"sid-a",
|
||||
incarnation=1,
|
||||
resume_token=created_a.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
resumed_b = await registry.resume(
|
||||
"sid-b",
|
||||
incarnation=4,
|
||||
resume_token=created_b.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
|
||||
assert [entry.payload["type"] for entry in resumed_a.replay_entries] == ["a-only"]
|
||||
assert [entry.payload["type"] for entry in resumed_b.replay_entries] == ["b-only"]
|
||||
with pytest.raises(ValueError, match="incarnation mismatch"):
|
||||
await registry.resume(
|
||||
"sid-a",
|
||||
incarnation=2,
|
||||
resume_token=resumed_a.resume_token.plaintext,
|
||||
last_received_server_event_seq=1,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_repr_never_contains_plaintext_tokens() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
)
|
||||
|
||||
async def send(payload):
|
||||
del payload
|
||||
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-repr", incarnation=0, send=send, close=close)
|
||||
await registry.detach("sid-repr", attachment_generation=1)
|
||||
entry = await registry.send_event(
|
||||
"sid-repr",
|
||||
{"type": "session.resumed", "resume_token": created.resume_token.plaintext},
|
||||
)
|
||||
|
||||
assert entry is not None
|
||||
assert created.resume_token.plaintext not in repr(created)
|
||||
assert created.resume_token.plaintext not in repr(entry)
|
||||
assert created.resume_token.plaintext not in repr(registry)
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_api_server_does_not_import_experimental_duplex_at_module_load() -> None:
|
||||
api_server = Path(__file__).parents[3] / "vllm_omni/entrypoints/openai/api_server.py"
|
||||
module = ast.parse(api_server.read_text())
|
||||
|
||||
top_level_imports = {
|
||||
node.module for node in module.body if isinstance(node, ast.ImportFrom) and isinstance(node.module, str)
|
||||
}
|
||||
|
||||
assert not any(module.startswith("vllm_omni.experimental.fullduplex") for module in top_level_imports)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -138,6 +138,112 @@ async def test_receive_config_accepts_client_legacy_aliases():
|
||||
assert config.frame_filter_threshold == 0.87
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_frame_ack_reports_receiver_buffer_state():
|
||||
ws = MockWebSocket(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.config",
|
||||
"model": "test",
|
||||
"enable_frame_filter": False,
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "video.frame",
|
||||
"data": _b64(_make_jpeg()),
|
||||
"frame_id": "frame-7",
|
||||
"pts_ms": 700,
|
||||
"capture_ts_ms": 1234.5,
|
||||
}
|
||||
),
|
||||
json.dumps({"type": "video.done"}),
|
||||
]
|
||||
)
|
||||
handler = QwenOmniStreamingVideoHandler(chat_service=object())
|
||||
|
||||
await handler.handle_session(ws)
|
||||
|
||||
ack = next(message for message in ws.sent if message.get("type") == "video.frame.ack")
|
||||
assert ack["frame_id"] == "frame-7"
|
||||
assert ack["pts_ms"] == 700
|
||||
assert ack["capture_ts_ms"] == 1234.5
|
||||
assert ack["accepted"] is True
|
||||
assert ack["buffered_frames"] == 1
|
||||
assert ack["server_receive_ts_ms"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_frames_consumed_is_emitted_after_engine_uses_frame_prompt():
|
||||
class OneOutputEngine:
|
||||
def generate(self, **_kwargs):
|
||||
async def _gen():
|
||||
await asyncio.sleep(0.05)
|
||||
yield _text_result("visible")
|
||||
|
||||
return _gen()
|
||||
|
||||
class CapturingHandler(QwenOmniStreamingVideoHandler):
|
||||
async def _preprocess_to_engine_prompt(self, request):
|
||||
return {"prompt": "with-video"}
|
||||
|
||||
ws = MockWebSocket(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.config",
|
||||
"model": "test",
|
||||
"modalities": ["text"],
|
||||
"num_frames": 1,
|
||||
"enable_frame_filter": False,
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "video.frame",
|
||||
"data": _b64(_make_jpeg()),
|
||||
"frame_id": "frame-9",
|
||||
"pts_ms": 900,
|
||||
"source_pts_ms": 880,
|
||||
"quality_profile": "balanced",
|
||||
}
|
||||
),
|
||||
json.dumps({"type": "video.query", "text": "describe"}),
|
||||
json.dumps({"type": "video.done"}),
|
||||
]
|
||||
)
|
||||
handler = CapturingHandler(
|
||||
chat_service=object(),
|
||||
engine_client=OneOutputEngine(),
|
||||
idle_timeout=2.0,
|
||||
)
|
||||
|
||||
await handler.handle_session(ws)
|
||||
|
||||
consumed = next(message for message in ws.sent if message.get("type") == "video.frames.consumed")
|
||||
assert consumed["frame_ids"] == ["frame-9"]
|
||||
assert consumed["latest_pts_ms"] == 900
|
||||
assert consumed["request_id"].startswith("video-")
|
||||
assert consumed["model_selected_ts_ms"] > 0
|
||||
assert consumed["frames"] == [
|
||||
{
|
||||
"frame_id": "frame-9",
|
||||
"pts_ms": 900,
|
||||
"source_pts_ms": 880,
|
||||
"quality_profile": "balanced",
|
||||
"receiver_received_ts_ms": consumed["frames"][0]["receiver_received_ts_ms"],
|
||||
"decoded_ready_ts_ms": consumed["frames"][0]["decoded_ready_ts_ms"],
|
||||
}
|
||||
]
|
||||
assert consumed["frames"][0]["receiver_received_ts_ms"] > 0
|
||||
assert consumed["frames"][0]["decoded_ready_ts_ms"] >= consumed["frames"][0]["receiver_received_ts_ms"]
|
||||
assert consumed["model_selected_ts_ms"] >= consumed["frames"][0]["decoded_ready_ts_ms"]
|
||||
assert ws.sent.index(consumed) < next(
|
||||
index for index, message in enumerate(ws.sent) if message.get("type") == "response.text.delta"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_in_video_sets_mm_processor_kwargs():
|
||||
captured_requests = []
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.engine.messages import OutputMessage
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
from vllm_omni.entrypoints.client_request_state import ClientRequestState
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
|
||||
DuplexOutputAction,
|
||||
DuplexOutputDecision,
|
||||
duplex_resource_request_id,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
from vllm_omni.experimental.fullduplex.output import attach_duplex_output_decision
|
||||
from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient
|
||||
from vllm_omni.metrics.stats import OrchestratorAggregator, StageRequestStats, StageStats
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
|
||||
def test_async_omni_uses_extracted_duplex_request_client():
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.engine = SimpleNamespace(num_stages=2)
|
||||
app.request_states = {}
|
||||
app._duplex_request_client = None
|
||||
|
||||
client = app._get_duplex_request_client()
|
||||
|
||||
assert isinstance(client, DuplexRequestClient)
|
||||
assert not hasattr(client, "_async_omni")
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_open_duplex_forwards_session_config_and_timeout():
|
||||
calls = []
|
||||
output_handler_starts = 0
|
||||
|
||||
async def open_duplex_session_async(session_id, **kwargs):
|
||||
calls.append((session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.engine = SimpleNamespace(open_duplex_session_async=open_duplex_session_async)
|
||||
|
||||
def start_output_handler():
|
||||
nonlocal output_handler_starts
|
||||
output_handler_starts += 1
|
||||
|
||||
app._final_output_handler = start_output_handler
|
||||
|
||||
result = await app.open_duplex_session_async(
|
||||
"sid",
|
||||
fence=DuplexFence("sid"),
|
||||
capabilities={"implementation_level": "model_native_duplex"},
|
||||
session_config={"instructions": "Be brief.", "idle_timeout_s": 30},
|
||||
timeout=7.5,
|
||||
)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert output_handler_starts == 1
|
||||
assert calls == [
|
||||
(
|
||||
"sid",
|
||||
{
|
||||
"session_mode": "duplex",
|
||||
"capabilities": {"implementation_level": "model_native_duplex"},
|
||||
"session_config": {"instructions": "Be brief.", "idle_timeout_s": 30},
|
||||
"fence": DuplexFence("sid"),
|
||||
"timeout": 7.5,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_duplex_runtime_controls_forward_timeout():
|
||||
calls = []
|
||||
cancelled_fence = DuplexFence("sid", epoch=2, turn_id=3)
|
||||
next_fence = DuplexFence("sid", epoch=3, turn_id=3)
|
||||
|
||||
async def append_duplex_input_async(session_id, **kwargs):
|
||||
calls.append(("append", session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
async def signal_duplex_turn_async(session_id, **kwargs):
|
||||
calls.append(("signal", session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
async def close_duplex_session_async(session_id, **kwargs):
|
||||
calls.append(("close", session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
async def touch_duplex_session_async(session_id, **kwargs):
|
||||
calls.append(("touch", session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
async def resume_duplex_session_async(session_id, **kwargs):
|
||||
calls.append(("resume", session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.request_states = {}
|
||||
app._final_output_handler = lambda: None
|
||||
app.engine = SimpleNamespace(
|
||||
append_duplex_input_async=append_duplex_input_async,
|
||||
signal_duplex_turn_async=signal_duplex_turn_async,
|
||||
close_duplex_session_async=close_duplex_session_async,
|
||||
touch_duplex_session_async=touch_duplex_session_async,
|
||||
resume_duplex_session_async=resume_duplex_session_async,
|
||||
)
|
||||
|
||||
await app.append_duplex_input_async(
|
||||
"sid",
|
||||
mode="append_audio_chunk",
|
||||
payload={},
|
||||
fence=cancelled_fence,
|
||||
timeout=12.5,
|
||||
)
|
||||
await app.signal_duplex_turn_async(
|
||||
"sid",
|
||||
event="barge_in",
|
||||
fence=cancelled_fence,
|
||||
next_fence=next_fence,
|
||||
timeout=13.5,
|
||||
)
|
||||
await app.close_duplex_session_async("sid", reason="done", fence=next_fence, timeout=14.5)
|
||||
await app.touch_duplex_session_async(
|
||||
"sid",
|
||||
fence=next_fence,
|
||||
activity=DuplexLeaseActivity.HEARTBEAT,
|
||||
timeout=15.5,
|
||||
)
|
||||
await app.resume_duplex_session_async(
|
||||
"sid",
|
||||
fence=next_fence,
|
||||
expected_lease_generation=3,
|
||||
timeout=16.5,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
"append",
|
||||
"sid",
|
||||
{
|
||||
"mode": "append_audio_chunk",
|
||||
"payload": {},
|
||||
"final": False,
|
||||
"expected_epoch": None,
|
||||
"fence": cancelled_fence,
|
||||
"timeout": 12.5,
|
||||
},
|
||||
),
|
||||
(
|
||||
"signal",
|
||||
"sid",
|
||||
{
|
||||
"event": "barge_in",
|
||||
"fence": cancelled_fence,
|
||||
"next_fence": next_fence,
|
||||
"timeout": 13.5,
|
||||
},
|
||||
),
|
||||
(
|
||||
"close",
|
||||
"sid",
|
||||
{
|
||||
"reason": "done",
|
||||
"fence": next_fence,
|
||||
"timeout": 14.5,
|
||||
},
|
||||
),
|
||||
(
|
||||
"touch",
|
||||
"sid",
|
||||
{
|
||||
"fence": next_fence,
|
||||
"activity": DuplexLeaseActivity.HEARTBEAT,
|
||||
"timeout": 15.5,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resume",
|
||||
"sid",
|
||||
{
|
||||
"fence": next_fence,
|
||||
"expected_lease_generation": 3,
|
||||
"timeout": 16.5,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_duplex_append_can_defer_data_plane_output_collection():
|
||||
app = object.__new__(AsyncOmni)
|
||||
request_id = duplex_resource_request_id(DuplexFence("sid"), "stage0")
|
||||
|
||||
async def append_duplex_input_async(session_id, **kwargs):
|
||||
del session_id, kwargs
|
||||
request_state = app.request_states.get(request_id)
|
||||
assert request_state is not None
|
||||
assert request_state.metrics is not None
|
||||
return {
|
||||
"stage_results": [
|
||||
{
|
||||
"result": {
|
||||
"data_plane_append": True,
|
||||
"request_id": request_id,
|
||||
"response_stage_id": 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
app.engine = SimpleNamespace(append_duplex_input_async=append_duplex_input_async)
|
||||
app.request_states = {}
|
||||
app._final_output_handler = lambda: None
|
||||
|
||||
result = await app.append_duplex_input_async(
|
||||
"sid",
|
||||
mode="append_audio_chunk",
|
||||
payload={},
|
||||
fence=DuplexFence("sid"),
|
||||
collect_outputs=False,
|
||||
)
|
||||
|
||||
assert "data_plane_outputs" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_duplex_append_forwards_fence():
|
||||
calls = []
|
||||
|
||||
async def append_duplex_input_async(session_id, **kwargs):
|
||||
calls.append((session_id, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.request_states = {}
|
||||
app._final_output_handler = lambda: None
|
||||
app.engine = SimpleNamespace(append_duplex_input_async=append_duplex_input_async)
|
||||
fence = DuplexFence("sid", epoch=1, turn_id=2)
|
||||
|
||||
await app.append_duplex_input_async(
|
||||
"sid",
|
||||
mode="append_audio_chunk",
|
||||
payload={},
|
||||
fence=fence,
|
||||
collect_outputs=False,
|
||||
)
|
||||
|
||||
assert calls[0][1]["fence"] is fence
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_duplex_collect_waits_for_response_stage():
|
||||
app = object.__new__(AsyncOmni)
|
||||
req_state = ClientRequestState("duplex-sid-e0-stage0")
|
||||
stage0_output = OmniRequestOutput(
|
||||
request_id="duplex-sid-e0-stage0",
|
||||
stage_id=0,
|
||||
finished=False,
|
||||
)
|
||||
stage1_output = OmniRequestOutput(
|
||||
request_id="duplex-sid-e0-stage0",
|
||||
stage_id=1,
|
||||
final_output_type="audio",
|
||||
finished=False,
|
||||
)
|
||||
await req_state.queue.put(
|
||||
OutputMessage(
|
||||
request_id="duplex-sid-e0-stage0",
|
||||
stage_id=0,
|
||||
engine_outputs=stage0_output,
|
||||
finished=False,
|
||||
)
|
||||
)
|
||||
await req_state.queue.put(
|
||||
OutputMessage(
|
||||
request_id="duplex-sid-e0-stage0",
|
||||
stage_id=1,
|
||||
engine_outputs=stage1_output,
|
||||
finished=False,
|
||||
)
|
||||
)
|
||||
|
||||
outputs = await app._collect_duplex_data_plane_outputs(
|
||||
"duplex-sid-e0-stage0",
|
||||
req_state,
|
||||
response_stage_id=1,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert outputs == [stage1_output]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_request_client_retains_output_route_at_segment_terminal():
|
||||
request_id = "duplex-sid-e0-stage0"
|
||||
request_state = ClientRequestState(request_id)
|
||||
request_states = {request_id: request_state}
|
||||
output = OmniRequestOutput(
|
||||
request_id=request_id,
|
||||
stage_id=1,
|
||||
final_output_type="audio",
|
||||
finished=True,
|
||||
)
|
||||
await request_state.queue.put(
|
||||
OutputMessage(
|
||||
request_id=request_id,
|
||||
stage_id=1,
|
||||
engine_outputs=output,
|
||||
finished=True,
|
||||
)
|
||||
)
|
||||
client = DuplexRequestClient(
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(
|
||||
request_states=request_states,
|
||||
num_stages=2,
|
||||
log_stats=False,
|
||||
),
|
||||
)
|
||||
|
||||
outputs = await client.collect_outputs(
|
||||
request_id,
|
||||
request_state,
|
||||
response_stage_id=1,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert outputs == [output]
|
||||
assert request_states[request_id] is request_state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_duplex_direct_output_keeps_stage_metrics():
|
||||
app = object.__new__(AsyncOmni)
|
||||
app._duplex_request_client = None
|
||||
app._final_output_handler = lambda: None
|
||||
request_id = "duplex-sid-e0-stage0"
|
||||
req_state = ClientRequestState(request_id)
|
||||
decision = DuplexOutputDecision(
|
||||
action=DuplexOutputAction.DIRECT_RESPONSE,
|
||||
metadata={
|
||||
"duplex_direct_response": True,
|
||||
"duplex_native_decision": "speak",
|
||||
},
|
||||
)
|
||||
direct_output = attach_duplex_output_decision(
|
||||
OmniRequestOutput(
|
||||
request_id=request_id,
|
||||
stage_id=0,
|
||||
final_output_type="text",
|
||||
finished=True,
|
||||
),
|
||||
decision,
|
||||
)
|
||||
await req_state.queue.put(
|
||||
OutputMessage(
|
||||
request_id=request_id,
|
||||
stage_id=0,
|
||||
engine_outputs=direct_output,
|
||||
metrics=StageRequestStats(
|
||||
batch_id=1,
|
||||
batch_size=1,
|
||||
num_tokens_in=160,
|
||||
num_tokens_out=8,
|
||||
stage_gen_time_ms=80.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(total_token=8, total_gen_time_ms=80.0),
|
||||
vllm_ttft_ms=12.5,
|
||||
vllm_tpot_ms=9.0,
|
||||
vllm_itl_ms=8.75,
|
||||
vllm_itls_ms=[8.0, 9.5],
|
||||
),
|
||||
finished=True,
|
||||
)
|
||||
)
|
||||
|
||||
outputs = await app._collect_duplex_data_plane_outputs(
|
||||
request_id,
|
||||
req_state,
|
||||
response_stage_id=1,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert outputs == [direct_output]
|
||||
assert direct_output.metrics["stage_metrics"]["0"]["vllm_ttft_ms"] == 12.5
|
||||
assert direct_output.metrics["stage_metrics"]["0"]["vllm_tpot_ms"] == 9.0
|
||||
assert direct_output.metrics["stage_metrics"]["0"]["vllm_itls_ms"] == [8.0, 9.5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_metrics_cursor_isolates_resumable_collect_windows():
|
||||
app = object.__new__(AsyncOmni)
|
||||
app._duplex_request_client = None
|
||||
app._final_output_handler = lambda: None
|
||||
request_id = "duplex-sid-e0-stage0"
|
||||
req_state = ClientRequestState(request_id)
|
||||
req_state.metrics = OrchestratorAggregator(
|
||||
num_stages=2,
|
||||
log_stats=False,
|
||||
wall_start_ts=0.0,
|
||||
final_stage_id_for_e2e=1,
|
||||
)
|
||||
|
||||
def stage_message(*, batch_id: int, tokens: int, ttft_ms: float, itls: list[float]) -> OutputMessage:
|
||||
decision = DuplexOutputDecision(
|
||||
action=DuplexOutputAction.DIRECT_RESPONSE,
|
||||
metadata={"duplex_direct_response": True, "duplex_native_decision": "speak"},
|
||||
)
|
||||
output = attach_duplex_output_decision(
|
||||
OmniRequestOutput(
|
||||
request_id=request_id,
|
||||
stage_id=0,
|
||||
final_output_type="text",
|
||||
finished=True,
|
||||
),
|
||||
decision,
|
||||
)
|
||||
return OutputMessage(
|
||||
request_id=request_id,
|
||||
stage_id=0,
|
||||
engine_outputs=output,
|
||||
metrics=StageRequestStats(
|
||||
batch_id=batch_id,
|
||||
batch_size=1,
|
||||
num_tokens_in=160,
|
||||
num_tokens_out=tokens,
|
||||
stage_gen_time_ms=80.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(total_token=tokens, total_gen_time_ms=80.0),
|
||||
vllm_ttft_ms=ttft_ms,
|
||||
vllm_tpot_ms=9.0,
|
||||
vllm_itl_ms=sum(itls) / len(itls),
|
||||
vllm_itls_ms=itls,
|
||||
),
|
||||
finished=True,
|
||||
)
|
||||
|
||||
await req_state.queue.put(stage_message(batch_id=1, tokens=71, ttft_ms=62.5, itls=[10.0, 11.0]))
|
||||
first = await app._collect_duplex_data_plane_outputs(
|
||||
request_id,
|
||||
req_state,
|
||||
response_stage_id=1,
|
||||
timeout=1.0,
|
||||
)
|
||||
await req_state.queue.put(stage_message(batch_id=2, tokens=155, ttft_ms=81.0, itls=[12.0, 13.0, 14.0]))
|
||||
second = await app._collect_duplex_data_plane_outputs(
|
||||
request_id,
|
||||
req_state,
|
||||
response_stage_id=1,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert first[0].metrics["stage_metrics"]["0"]["num_tokens_out"] == 71
|
||||
second_stage0 = second[0].metrics["stage_metrics"]["0"]
|
||||
assert second_stage0["num_tokens_out"] == 155
|
||||
assert second_stage0["vllm_ttft_ms"] == 81.0
|
||||
assert second_stage0["vllm_itls_ms"] == [12.0, 13.0, 14.0]
|
||||
|
||||
|
||||
def test_async_omni_duplex_direct_output_prefers_outer_control_metadata():
|
||||
decision = DuplexOutputDecision(
|
||||
action=DuplexOutputAction.DIRECT_RESPONSE,
|
||||
metadata={
|
||||
"duplex_direct_response": True,
|
||||
"duplex_native_decision": "listen",
|
||||
},
|
||||
)
|
||||
output = attach_duplex_output_decision(
|
||||
OmniRequestOutput(
|
||||
request_id="duplex-sid-e0-stage0",
|
||||
stage_id=0,
|
||||
request_output=SimpleNamespace(
|
||||
outputs=[],
|
||||
multimodal_output=SimpleNamespace(kind="processed-payload"),
|
||||
_custom_output={"inner": "completion"},
|
||||
),
|
||||
),
|
||||
decision,
|
||||
)
|
||||
|
||||
assert AsyncOmni._is_direct_duplex_data_plane_response(output)
|
||||
|
||||
|
||||
def test_async_omni_drops_unregistered_duplex_prefixed_output():
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.request_states = {}
|
||||
output = OmniRequestOutput(request_id="duplex-unregistered-e0-stage0", stage_id=0)
|
||||
|
||||
keep_processing, request_id, stage_id, request_state = app._handle_output_message(
|
||||
OutputMessage(
|
||||
request_id=output.request_id,
|
||||
stage_id=0,
|
||||
engine_outputs=output,
|
||||
finished=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert keep_processing is True
|
||||
assert request_id is stage_id is request_state is None
|
||||
assert app.request_states == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_duplex_collect_wraps_raw_response_stage_output():
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.log_stats = False
|
||||
app._enable_ar_profiler = False
|
||||
app.request_states = {}
|
||||
app.prom_metrics = SimpleNamespace(
|
||||
set_running=lambda value: None,
|
||||
set_waiting=lambda value: None,
|
||||
)
|
||||
app.engine = SimpleNamespace(
|
||||
num_stages=2,
|
||||
get_stage_metadata=lambda stage_id: SimpleNamespace(
|
||||
final_output=stage_id == 1,
|
||||
final_output_type="audio",
|
||||
),
|
||||
)
|
||||
req_state = ClientRequestState("duplex-sid-e0-stage0")
|
||||
raw_stage1_output = SimpleNamespace(
|
||||
finished=False,
|
||||
outputs=[],
|
||||
stage_durations={},
|
||||
peak_memory_mb=0.0,
|
||||
final_output_type="audio",
|
||||
)
|
||||
await req_state.queue.put(
|
||||
OutputMessage(
|
||||
request_id="duplex-sid-e0-stage0",
|
||||
stage_id=1,
|
||||
engine_outputs=raw_stage1_output,
|
||||
finished=False,
|
||||
)
|
||||
)
|
||||
|
||||
outputs = await app._collect_duplex_data_plane_outputs(
|
||||
"duplex-sid-e0-stage0",
|
||||
req_state,
|
||||
response_stage_id=1,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].request_id == "duplex-sid-e0-stage0"
|
||||
assert outputs[0].finished is False
|
||||
assert outputs[0].stage_id == 1
|
||||
assert outputs[0].final_output_type == "audio"
|
||||
assert outputs[0].request_output is raw_stage1_output
|
||||
|
||||
|
||||
def test_async_omni_duplex_request_info_includes_response_stage():
|
||||
request_id, response_stage_id = AsyncOmni._duplex_data_plane_request_info(
|
||||
{
|
||||
"stage_results": [
|
||||
{
|
||||
"result": {
|
||||
"data_plane_append": True,
|
||||
"request_id": "duplex-sid-e0-stage0",
|
||||
"response_stage_id": 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert request_id == "duplex-sid-e0-stage0"
|
||||
assert response_stage_id == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_request_client_retains_output_route_when_close_fails():
|
||||
fence = DuplexFence("sid-close-route")
|
||||
request_id = duplex_resource_request_id(fence, "stage0")
|
||||
request_states = {request_id: ClientRequestState(request_id)}
|
||||
|
||||
async def close_duplex_session_async(session_id, **kwargs):
|
||||
del session_id, kwargs
|
||||
raise RuntimeError("close failed")
|
||||
|
||||
client = DuplexRequestClient(
|
||||
SimpleNamespace(close_duplex_session_async=close_duplex_session_async),
|
||||
SimpleNamespace(request_states=request_states),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="close failed"):
|
||||
await client.close(fence.session_id, reason="test", fence=fence, timeout=1.0)
|
||||
|
||||
assert request_id in request_states
|
||||
@@ -0,0 +1,123 @@
|
||||
import pytest
|
||||
|
||||
from tests.entrypoints.openai_api.test_duplex_handler import FakeChatService, TimedWebSocket
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
from vllm_omni.experimental.fullduplex.openai.protocol import DuplexSession, DuplexSessionConfig
|
||||
from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FenceRecordingEngine:
|
||||
output_modalities = ["text", "audio"]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, DuplexFence | None]] = []
|
||||
self.signal_next_fences: list[DuplexFence | None] = []
|
||||
|
||||
async def open_duplex_session_async(self, session_id, *, fence=None, **kwargs):
|
||||
del session_id, kwargs
|
||||
self.calls.append(("open", fence))
|
||||
return {"ok": True}
|
||||
|
||||
async def append_duplex_input_async(self, session_id, *, fence=None, **kwargs):
|
||||
del session_id, kwargs
|
||||
self.calls.append(("append", fence))
|
||||
return {"ok": True}
|
||||
|
||||
async def signal_duplex_turn_async(
|
||||
self,
|
||||
session_id,
|
||||
*,
|
||||
event,
|
||||
fence=None,
|
||||
next_fence=None,
|
||||
timeout=None,
|
||||
):
|
||||
del session_id, event, timeout
|
||||
self.calls.append(("signal", fence))
|
||||
self.signal_next_fences.append(next_fence)
|
||||
return {"ok": True}
|
||||
|
||||
async def close_duplex_session_async(self, session_id, *, fence=None, **kwargs):
|
||||
del session_id, kwargs
|
||||
self.calls.append(("close", fence))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_omni_forwards_fence_for_all_runtime_operations():
|
||||
engine = FenceRecordingEngine()
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.engine = engine
|
||||
app.request_states = {}
|
||||
app._final_output_handler = lambda: None
|
||||
fence = DuplexFence("sid", epoch=1, turn_id=2)
|
||||
|
||||
await app.open_duplex_session_async("sid", fence=fence)
|
||||
await app.append_duplex_input_async(
|
||||
"sid", mode="append_audio_chunk", payload={}, fence=fence, collect_outputs=False
|
||||
)
|
||||
await app.signal_duplex_turn_async("sid", event="turn.end", fence=fence)
|
||||
await app.close_duplex_session_async("sid", reason="done", fence=fence)
|
||||
|
||||
assert engine.calls == [(operation, fence) for operation in ("open", "append", "signal", "close")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_handler_passes_current_session_fence_for_runtime_controls():
|
||||
engine = FenceRecordingEngine()
|
||||
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
|
||||
session = DuplexSession(
|
||||
session_id="sid",
|
||||
config=DuplexSessionConfig(),
|
||||
epoch=1,
|
||||
turn_id=2,
|
||||
)
|
||||
ws = TimedWebSocket()
|
||||
fence = DuplexFence("sid", epoch=1, turn_id=2)
|
||||
|
||||
await handler._open_runtime_session(session, ws.send_json)
|
||||
await handler._signal_runtime_session(session, "turn.end", send_json=ws.send_json)
|
||||
await handler._close_runtime_session(session, reason="done", send_json=ws.send_json)
|
||||
|
||||
assert engine.calls == [(operation, fence) for operation in ("open", "signal", "close")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_signal_uses_fence_captured_before_session_turn_advances():
|
||||
engine = FenceRecordingEngine()
|
||||
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
|
||||
session = DuplexSession(session_id="sid", config=DuplexSessionConfig())
|
||||
captured_fence = DuplexFence("sid", epoch=0, turn_id=0)
|
||||
session.turn_id = 1
|
||||
|
||||
await handler._signal_runtime_session(
|
||||
session,
|
||||
"input.commit",
|
||||
fence=captured_fence,
|
||||
)
|
||||
|
||||
assert engine.calls == [("signal", captured_fence)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_handler_forwards_cancel_fence_through_async_omni_facade():
|
||||
engine = FenceRecordingEngine()
|
||||
app = object.__new__(AsyncOmni)
|
||||
app.engine = engine
|
||||
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(app))
|
||||
session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig())
|
||||
cancelled_fence = DuplexFence("sid-cancel", epoch=2, turn_id=3)
|
||||
next_fence = DuplexFence("sid-cancel", epoch=3, turn_id=3)
|
||||
|
||||
assert await handler._signal_runtime_session(
|
||||
session,
|
||||
"input.cancel",
|
||||
fence=cancelled_fence,
|
||||
next_fence=next_fence,
|
||||
)
|
||||
|
||||
assert engine.calls == [("signal", cancelled_fence)]
|
||||
assert engine.signal_next_fences == [next_fence]
|
||||
@@ -108,6 +108,8 @@ class FakeAsyncOmniEngine:
|
||||
self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)]
|
||||
self.input_processor = None
|
||||
self.endpoint_restrictions = ()
|
||||
self.duplex_session_config = None
|
||||
self.duplex_serving_adapter_path = None
|
||||
|
||||
self.output_q: queue.Queue[Any] = queue.Queue()
|
||||
self.submitted: list[dict[str, Any]] = []
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import base64
|
||||
import importlib.util
|
||||
import sys
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
DEMO_PATH = Path(__file__).resolve().parents[2] / "examples/online_serving/minicpmo/realtime_duplex_demo.py"
|
||||
|
||||
|
||||
def _load_demo_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"minicpmo_realtime_duplex_simple_demo_test",
|
||||
DEMO_PATH,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_post_commit_decision_ignores_streaming_listens_before_commit_ack():
|
||||
demo = _load_demo_module()
|
||||
events = [
|
||||
{"type": "session.created"},
|
||||
{"type": "response.listen"},
|
||||
{"type": "response.listen"},
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
]
|
||||
|
||||
commit_index = demo._input_committed_index(events, after_index=0)
|
||||
|
||||
assert commit_index == 3
|
||||
assert demo._post_commit_model_decision(events, commit_index) is None
|
||||
|
||||
|
||||
def test_post_commit_decision_accepts_listen_after_commit_ack():
|
||||
demo = _load_demo_module()
|
||||
events = [
|
||||
{"type": "response.listen"},
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
{"type": "response.listen"},
|
||||
]
|
||||
|
||||
commit_index = demo._input_committed_index(events, after_index=0)
|
||||
|
||||
assert demo._post_commit_model_decision(events, commit_index) == "listen"
|
||||
|
||||
|
||||
def test_post_commit_decision_accepts_drained_speak_after_commit_ack():
|
||||
demo = _load_demo_module()
|
||||
events = [
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
{"type": "response.created", "response": {"id": "resp-a"}},
|
||||
{"type": "response.audio.delta", "response_id": "resp-a", "delta": "AAAA"},
|
||||
{"type": "response.done", "response_id": "resp-a"},
|
||||
]
|
||||
|
||||
commit_index = demo._input_committed_index(events, after_index=0)
|
||||
|
||||
assert demo._post_commit_model_decision(events, commit_index) == "speak"
|
||||
|
||||
|
||||
def test_post_commit_decision_ignores_cancelled_response():
|
||||
demo = _load_demo_module()
|
||||
events = [
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
{"type": "response.done", "response": {"status": "cancelled"}},
|
||||
]
|
||||
|
||||
commit_index = demo._input_committed_index(events, after_index=0)
|
||||
|
||||
assert demo._post_commit_model_decision(events, commit_index) is None
|
||||
|
||||
|
||||
def test_commit_ack_search_starts_after_stream_cursor():
|
||||
demo = _load_demo_module()
|
||||
events = [
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
{"type": "response.listen"},
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
]
|
||||
|
||||
assert demo._input_committed_index(events, after_index=1) == 2
|
||||
|
||||
|
||||
def test_exact_model_unit_boundary_does_not_require_an_extra_decision():
|
||||
demo = _load_demo_module()
|
||||
unit_bytes = demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE
|
||||
|
||||
assert not demo._has_residual_model_unit(b"\0" * (unit_bytes * 6), chunk_period_ms=1000)
|
||||
|
||||
|
||||
def test_partial_model_unit_requires_a_post_commit_decision():
|
||||
demo = _load_demo_module()
|
||||
unit_bytes = demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE
|
||||
|
||||
assert demo._has_residual_model_unit(b"\0" * (unit_bytes * 6 + 512), chunk_period_ms=1000)
|
||||
|
||||
|
||||
def test_latest_streaming_decision_is_used_at_exact_boundary():
|
||||
demo = _load_demo_module()
|
||||
events = [
|
||||
{"type": "response.listen"},
|
||||
{"type": "response.listen"},
|
||||
{"type": "input_audio_buffer.committed"},
|
||||
]
|
||||
|
||||
assert demo._latest_model_decision(events, after_index=0) == "listen"
|
||||
|
||||
|
||||
def test_ref_audio_data_url_encodes_explicit_wav(tmp_path):
|
||||
demo = _load_demo_module()
|
||||
ref = tmp_path / "ref.wav"
|
||||
ref.write_bytes(b"RIFFfake")
|
||||
|
||||
assert demo._ref_audio_data_url(str(ref)) == "data:audio/wav;base64,UklGRmZha2U="
|
||||
assert demo._ref_audio_data_url(None) is None
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_requires_ref_audio(monkeypatch):
|
||||
demo = _load_demo_module()
|
||||
monkeypatch.setattr(
|
||||
demo.sys,
|
||||
"argv",
|
||||
[
|
||||
"realtime_duplex_demo.py",
|
||||
"--input-wav",
|
||||
"input.wav",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
demo.parse_args()
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_accepts_explicit_ref_audio(monkeypatch):
|
||||
demo = _load_demo_module()
|
||||
monkeypatch.setattr(
|
||||
demo.sys,
|
||||
"argv",
|
||||
[
|
||||
"realtime_duplex_demo.py",
|
||||
"--input-wav",
|
||||
"input.wav",
|
||||
"--ref-audio",
|
||||
"ref.wav",
|
||||
],
|
||||
)
|
||||
|
||||
args = demo.parse_args()
|
||||
|
||||
assert args.ref_audio == "ref.wav"
|
||||
|
||||
|
||||
def test_open_streaming_response_requires_post_commit_drain():
|
||||
demo = _load_demo_module()
|
||||
|
||||
assert demo._response_in_progress([{"type": "response.created"}])
|
||||
assert not demo._response_in_progress(
|
||||
[
|
||||
{"type": "response.created"},
|
||||
{"type": "response.done"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_output_writer_persists_audio_deltas_as_they_arrive(tmp_path, capsys):
|
||||
demo = _load_demo_module()
|
||||
writer = demo._StreamingOutputWriter(tmp_path)
|
||||
first_pcm = b"\x01\x00\x02\x00"
|
||||
second_pcm = b"\x03\x00"
|
||||
|
||||
writer.handle(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(first_pcm).decode("ascii"),
|
||||
"sample_rate_hz": 24_000,
|
||||
}
|
||||
)
|
||||
writer.handle(
|
||||
{
|
||||
"type": "response.audio_transcript.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": "你好",
|
||||
}
|
||||
)
|
||||
writer.handle(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(second_pcm).decode("ascii"),
|
||||
"sample_rate_hz": 24_000,
|
||||
}
|
||||
)
|
||||
|
||||
assert (tmp_path / "output.pcm").read_bytes() == first_pcm + second_pcm
|
||||
chunk_paths = sorted((tmp_path / "audio_chunks").glob("*.wav"))
|
||||
assert [path.name for path in chunk_paths] == ["chunk_0001.wav", "chunk_0002.wav"]
|
||||
with wave.open(str(chunk_paths[0]), "rb") as chunk_wav:
|
||||
assert chunk_wav.getframerate() == 24_000
|
||||
assert chunk_wav.readframes(chunk_wav.getnframes()) == first_pcm
|
||||
assert writer.audio_chunk_paths == chunk_paths
|
||||
stderr = capsys.readouterr().err
|
||||
assert "audio chunk 1" in stderr
|
||||
assert "你好" in stderr
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
SERVER_PATH = Path(__file__).resolve().parents[2] / "examples/online_serving/minicpmo/realtime_web/server.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"minicpmo_realtime_web_server_test",
|
||||
SERVER_PATH,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
server = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = server
|
||||
spec.loader.exec_module(server)
|
||||
|
||||
APP_DIR = server.APP_DIR
|
||||
STATIC_DIR = server.STATIC_DIR
|
||||
_join_ws_url = server._join_ws_url
|
||||
build_app = server.build_app
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_join_ws_url_preserves_realtime_query():
|
||||
assert (
|
||||
_join_ws_url(
|
||||
"ws://127.0.0.1:8099/",
|
||||
"/v1/realtime",
|
||||
"duplex=1&model=openbmb%2FMiniCPM-o-4_5",
|
||||
)
|
||||
== "ws://127.0.0.1:8099/v1/realtime?duplex=1&model=openbmb%2FMiniCPM-o-4_5"
|
||||
)
|
||||
|
||||
|
||||
def test_build_app_serves_health_and_injected_client_config():
|
||||
app = build_app(
|
||||
ws_backend="ws://127.0.0.1:9001",
|
||||
model="local/MiniCPM-o-4_5",
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
health = client.get("/healthz")
|
||||
assert health.status_code == 200
|
||||
assert health.text == "ok"
|
||||
|
||||
index = client.get("/")
|
||||
assert index.status_code == 200
|
||||
assert '"model": "local/MiniCPM-o-4_5"' in index.text
|
||||
assert '"realtimePath": "v1/realtime"' in index.text
|
||||
assert "__FULL_DUPLEX_CONFIG__" not in index.text
|
||||
|
||||
|
||||
def test_build_app_injects_public_realtime_url():
|
||||
public_realtime_url = "wss://proxy.example.test/backend/v1/realtime"
|
||||
app = build_app(
|
||||
ws_backend="ws://127.0.0.1:9001",
|
||||
public_realtime_url=public_realtime_url,
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
index = client.get("/")
|
||||
|
||||
assert index.status_code == 200
|
||||
assert f'"realtimePath": "{public_realtime_url}"' in index.text
|
||||
|
||||
|
||||
def test_build_app_versions_client_bundle_and_disables_index_cache():
|
||||
client = TestClient(build_app())
|
||||
expected_version_hash = hashlib.sha256()
|
||||
for asset_name in ("app.js", "pcm_worklet.js", "playback_worklet.js"):
|
||||
expected_version_hash.update((STATIC_DIR / asset_name).read_bytes())
|
||||
expected_version = expected_version_hash.hexdigest()[:12]
|
||||
|
||||
assert (APP_DIR / "index.html").is_file()
|
||||
assert (STATIC_DIR / "app.js").is_file()
|
||||
assert not (APP_DIR.parent / "static").exists()
|
||||
assert not (STATIC_DIR / "index.html").exists()
|
||||
|
||||
index = client.get("/")
|
||||
|
||||
assert index.status_code == 200
|
||||
assert f'src="static/app.js?v={expected_version}"' in index.text
|
||||
assert index.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_build_app_exposes_realtime_websocket_and_static_assets():
|
||||
app = build_app()
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/v1/realtime" in paths
|
||||
assert "/static" in paths
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/static/app.js").status_code == 200
|
||||
assert client.get("/static/pcm_worklet.js").status_code == 200
|
||||
assert client.get("/static/playback_worklet.js").status_code == 200
|
||||
@@ -0,0 +1,209 @@
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "examples" / "online_serving" / "minicpmo" / "realtime_web"
|
||||
APP_ROOT = ROOT / "app"
|
||||
STATIC_ROOT = APP_ROOT / "static"
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_page_exposes_focused_call_conversation_and_log_surfaces():
|
||||
html = (APP_ROOT / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="callButton"' in html
|
||||
assert 'id="muteButton"' in html
|
||||
assert 'id="connectionState"' in html
|
||||
assert 'id="modelState"' in html
|
||||
assert 'id="conversation"' in html
|
||||
assert 'id="eventLog"' in html
|
||||
assert "<details" in html
|
||||
assert "Automatic barge-in" not in html
|
||||
assert "Server VAD" not in html
|
||||
|
||||
|
||||
def test_client_uses_proxy_relative_realtime_url_and_model_policy_session():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "new URL(config.realtimePath, window.location.href)" in source
|
||||
assert "url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'" in source
|
||||
assert "url.searchParams.set('autostart', '0')" in source
|
||||
assert "url.searchParams.set('minicpmo45_native_duplex', '1')" in source
|
||||
assert "auto_response: true" in source
|
||||
assert "input_audio_buffer.append" in source
|
||||
assert "input_audio_buffer.commit" not in source
|
||||
assert "playback.ack" in source
|
||||
assert "event.event || event" in source
|
||||
assert "response.audio.delta" in source
|
||||
assert "response.audio_transcript.delta" in source
|
||||
assert "conversation.item.input_audio_transcription" in source
|
||||
assert "force_barge_in" not in source
|
||||
assert "server_vad" not in source
|
||||
assert "type: 'response.create'" not in source
|
||||
assert 'type: "response.create"' not in source
|
||||
|
||||
|
||||
def test_web_server_requires_ref_audio_for_audio_output_session():
|
||||
source = (ROOT / "server.py").read_text(encoding="utf-8")
|
||||
|
||||
ref_audio_arg = re.search(
|
||||
r"parser\.add_argument\(\s*\"--ref-audio\",(?P<body>.*?)\n\s*\)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert ref_audio_arg is not None
|
||||
assert "required=True" in ref_audio_arg.group("body")
|
||||
assert "Optional reference voice" not in ref_audio_arg.group("body")
|
||||
|
||||
|
||||
def test_client_sends_ref_audio_in_realtime_session_contract():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "if (config.refAudio) session.ref_audio = config.refAudio;" in source
|
||||
assert "extraBody.ref_audio" not in source
|
||||
|
||||
|
||||
def test_client_has_transactional_cleanup_and_visible_event_logging():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "async function stopSession" in source
|
||||
assert "type: 'session.close'" in source
|
||||
assert "waitForSessionClosed" in source
|
||||
assert "SESSION_CLOSE_TIMEOUT_MS" in source
|
||||
assert "case 'session.closed':" in source
|
||||
assert "track.stop()" in source
|
||||
assert "clearInterval(sendTimer)" in source
|
||||
assert "appendEventLog(event)" in source
|
||||
assert "stopSession({ terminal: false })" in source
|
||||
|
||||
|
||||
def test_client_keeps_microphone_upload_active_during_assistant_playback():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
upload_gate = re.search(r"function microphoneUploadEnabled\(\) \{(?P<body>.*?)\n \}", source, re.DOTALL)
|
||||
begin_assistant = re.search(r"function beginAssistant\(responseId\) \{(?P<body>.*?)\n \}", source, re.DOTALL)
|
||||
|
||||
assert upload_gate is not None
|
||||
assert "return running && !muted;" in upload_gate.group("body")
|
||||
assert "assistantActive" not in upload_gate.group("body")
|
||||
assert begin_assistant is not None
|
||||
assert "pendingCapture = []" not in begin_assistant.group("body")
|
||||
|
||||
|
||||
def test_audio_worklets_define_capture_and_playback_processors():
|
||||
capture = (STATIC_ROOT / "pcm_worklet.js").read_text(encoding="utf-8")
|
||||
playback = (STATIC_ROOT / "playback_worklet.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "registerProcessor('fullduplex-pcm-capture'" in capture
|
||||
assert "Int16Array" in capture
|
||||
assert "registerProcessor('fullduplex-pcm-playback'" in playback
|
||||
assert "playback-drained" in playback
|
||||
assert "clear" in playback
|
||||
|
||||
|
||||
def test_audio_worklet_urls_use_the_static_asset_version():
|
||||
server = (ROOT / "server.py").read_text(encoding="utf-8")
|
||||
app = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert '"appVersion": app_version' in server
|
||||
assert 'STATIC_DIR / "playback_worklet.js"' in server
|
||||
assert 'STATIC_DIR / "pcm_worklet.js"' in server
|
||||
assert "staticAssetUrl('static/playback_worklet.js')" in app
|
||||
assert "staticAssetUrl('static/pcm_worklet.js')" in app
|
||||
|
||||
|
||||
def test_playback_worklet_buffers_first_400ms_and_reports_underruns():
|
||||
app = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
playback = (STATIC_ROOT / "playback_worklet.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "INITIAL_PLAYBACK_BUFFER_MS = 400" in app
|
||||
assert "initialBufferMs" in app
|
||||
assert "responseId" in app
|
||||
assert "playback-underrun" in app
|
||||
assert "underrunMs" in app
|
||||
assert "initialBufferFrames" in playback
|
||||
assert "playback-underrun" in playback
|
||||
assert "underrunFrames" in playback
|
||||
assert "underrunMs" in playback
|
||||
|
||||
|
||||
def test_playback_worklet_waits_before_playing_and_rebuffers_after_underrun():
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node is required for the AudioWorklet regression test")
|
||||
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
global.sampleRate = 1000;
|
||||
global.AudioWorkletProcessor = class {
|
||||
constructor() {
|
||||
this.port = { onmessage: null, postMessage: () => {} };
|
||||
}
|
||||
};
|
||||
let Processor = null;
|
||||
global.registerProcessor = (_name, processor) => { Processor = processor; };
|
||||
vm.runInThisContext(fs.readFileSync(process.argv[1], 'utf8'));
|
||||
|
||||
const processor = new Processor();
|
||||
const render = () => {
|
||||
const output = new Float32Array(100);
|
||||
processor.process([], [[output]]);
|
||||
return output;
|
||||
};
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) throw new Error(message);
|
||||
};
|
||||
|
||||
const first = new Int16Array(150);
|
||||
first.fill(16384);
|
||||
processor.handleMessage({
|
||||
type: 'audio',
|
||||
pcm: first,
|
||||
responseId: 'response-1',
|
||||
initialBufferMs: 400,
|
||||
});
|
||||
assert(!processor.started, 'large first delta must not bypass wall-clock prebuffer');
|
||||
assert(render().every((sample) => sample === 0), 'first prebuffer render must be silent');
|
||||
assert(render().every((sample) => sample === 0), 'second prebuffer render must be silent');
|
||||
assert(render().every((sample) => sample === 0), 'third prebuffer render must be silent');
|
||||
assert(render().every((sample) => sample === 0), 'fourth prebuffer render must be silent');
|
||||
const firstPlayback = render();
|
||||
assert(firstPlayback.some((sample) => sample !== 0), 'playback must start after prebuffer');
|
||||
|
||||
const underrun = render();
|
||||
assert(!processor.started, 'an empty queue must return to buffering');
|
||||
assert(underrun[underrun.length - 1] === 0, 'underrun boundary must fade to zero');
|
||||
|
||||
const resumed = new Int16Array(300);
|
||||
resumed.fill(8192);
|
||||
processor.handleMessage({
|
||||
type: 'audio',
|
||||
pcm: resumed,
|
||||
responseId: 'response-1',
|
||||
initialBufferMs: 400,
|
||||
});
|
||||
assert(render().every((sample) => sample === 0), 'resume must rebuild jitter buffer');
|
||||
assert(render().every((sample) => sample === 0), 'resume must keep rebuilding jitter buffer');
|
||||
assert(render().every((sample) => sample === 0), 'resume must keep waiting');
|
||||
assert(render().every((sample) => sample === 0), 'resume must wait the full buffer interval');
|
||||
const resumedPlayback = render();
|
||||
assert(resumedPlayback.some((sample) => sample !== 0), 'playback must resume after rebuffer');
|
||||
assert(
|
||||
Math.abs(resumedPlayback[0]) < Math.abs(resumedPlayback[50]),
|
||||
'resumed playback must fade in instead of jumping from silence',
|
||||
);
|
||||
"""
|
||||
)
|
||||
subprocess.run(
|
||||
[node, "-e", script, str(STATIC_ROOT / "playback_worklet.js")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import (
|
||||
DuplexControlClient,
|
||||
DuplexControlRequestError,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import (
|
||||
DuplexControlError,
|
||||
DuplexControlResultMessage,
|
||||
DuplexFence,
|
||||
ResumeDuplexSessionMessage,
|
||||
TouchDuplexSessionMessage,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _Transport:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def execute(self, key, message, **kwargs):
|
||||
self.calls.append((key, message, kwargs))
|
||||
return DuplexControlResultMessage(
|
||||
control_id=message.control_id,
|
||||
fence=message.fence,
|
||||
operation="touch" if isinstance(message, TouchDuplexSessionMessage) else "resume",
|
||||
session_id=message.session_id,
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
|
||||
|
||||
class _UnsupportedTransport:
|
||||
def execute(self, key, message, **kwargs):
|
||||
del key, kwargs
|
||||
return DuplexControlResultMessage(
|
||||
control_id=message.control_id,
|
||||
fence=message.fence,
|
||||
operation="touch",
|
||||
session_id=message.session_id,
|
||||
ok=False,
|
||||
stage_results=[{"result": {"supported": False}}],
|
||||
unsupported_count=1,
|
||||
error=DuplexControlError(code="invalid_capability", message="unsupported", retryable=False),
|
||||
accepted_fence=DuplexFence(message.session_id, epoch=2),
|
||||
lease_generation=7,
|
||||
)
|
||||
|
||||
|
||||
def test_control_client_routes_touch_and_resume_by_control_id() -> None:
|
||||
transport = _Transport()
|
||||
control_ids = iter(("touch-id", "resume-id"))
|
||||
client = DuplexControlClient(transport, control_id_factory=lambda: next(control_ids))
|
||||
fence = DuplexFence("sid-client")
|
||||
|
||||
assert (
|
||||
client.touch(
|
||||
fence.session_id,
|
||||
fence=fence,
|
||||
activity=DuplexLeaseActivity.PLAYBACK_ACK,
|
||||
timeout=2.0,
|
||||
)["ok"]
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
client.resume(
|
||||
fence.session_id,
|
||||
fence=fence,
|
||||
expected_lease_generation=7,
|
||||
timeout=3.0,
|
||||
)["ok"]
|
||||
is True
|
||||
)
|
||||
|
||||
touch_key, touch_message, touch_kwargs = transport.calls[0]
|
||||
assert touch_key == ("duplex", "touch-id")
|
||||
assert isinstance(touch_message, TouchDuplexSessionMessage)
|
||||
assert touch_message.activity == DuplexLeaseActivity.PLAYBACK_ACK.value
|
||||
assert touch_kwargs["timeout"] == 2.0
|
||||
|
||||
resume_key, resume_message, resume_kwargs = transport.calls[1]
|
||||
assert resume_key == ("duplex", "resume-id")
|
||||
assert isinstance(resume_message, ResumeDuplexSessionMessage)
|
||||
assert resume_message.expected_lease_generation == 7
|
||||
assert resume_kwargs["timeout"] == 3.0
|
||||
|
||||
|
||||
def test_control_client_treats_ok_false_as_authoritative_failure() -> None:
|
||||
client = DuplexControlClient(_UnsupportedTransport(), control_id_factory=lambda: "unsupported")
|
||||
fence = DuplexFence("sid-unsupported")
|
||||
|
||||
with pytest.raises(DuplexControlRequestError, match="duplex touch failed") as exc_info:
|
||||
client.touch(
|
||||
fence.session_id,
|
||||
fence=fence,
|
||||
activity=DuplexLeaseActivity.HEARTBEAT,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "invalid_capability"
|
||||
assert exc_info.value.retryable is False
|
||||
assert exc_info.value.accepted_fence == DuplexFence(fence.session_id, epoch=2)
|
||||
assert exc_info.value.lease_generation == 7
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Focused tests for typed deployment-only runtime configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.config.stage_config import (
|
||||
DuplexSessionRuntimeConfig,
|
||||
load_deploy_config,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_duplex_session_runtime_defaults_are_typed_and_immutable(tmp_path) -> None:
|
||||
deploy_path = tmp_path / "duplex.yaml"
|
||||
deploy_path.write_text("duplex_session: {}\nstages: []\n", encoding="utf-8")
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
|
||||
assert isinstance(deploy.duplex_session, DuplexSessionRuntimeConfig)
|
||||
assert deploy.duplex_session.idle_ttl_s == 300.0
|
||||
assert deploy.duplex_session.disconnect_grace_s == 30.0
|
||||
assert deploy.duplex_session.reaper_interval_s == 5.0
|
||||
assert deploy.duplex_session.resume_replay_ttl_s == 60.0
|
||||
assert deploy.duplex_session.resume_replay_max_bytes_per_session == 8 * 1024 * 1024
|
||||
assert deploy.duplex_session.max_pending_input_bytes_per_session == 16 * 1024 * 1024
|
||||
assert deploy.duplex_session.max_pending_turns_per_session == 4
|
||||
assert deploy.duplex_session.max_sessions == 1
|
||||
assert deploy.duplex_session.completed_append_cache_size == 256
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
deploy.duplex_session.idle_ttl_s = 1.0 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_duplex_session_runtime_accepts_disabled_idle_expiry(tmp_path) -> None:
|
||||
deploy_path = tmp_path / "duplex.yaml"
|
||||
deploy_path.write_text("duplex_session:\n idle_ttl_s: null\nstages: []\n", encoding="utf-8")
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
|
||||
assert deploy.duplex_session.idle_ttl_s is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "value"),
|
||||
[
|
||||
("idle_ttl_s", 0),
|
||||
("idle_ttl_s", -1),
|
||||
("disconnect_grace_s", 0),
|
||||
("reaper_interval_s", -1),
|
||||
("resume_replay_ttl_s", 0),
|
||||
("resume_replay_max_bytes_per_session", -1),
|
||||
("max_pending_input_bytes_per_session", 0),
|
||||
("max_pending_turns_per_session", -1),
|
||||
("max_sessions", 0),
|
||||
("completed_append_cache_size", 0),
|
||||
],
|
||||
)
|
||||
def test_duplex_session_runtime_rejects_non_positive_values(tmp_path, name: str, value: int) -> None:
|
||||
deploy_path = tmp_path / "duplex.yaml"
|
||||
deploy_path.write_text(f"duplex_session:\n {name}: {value}\nstages: []\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match=rf"duplex_session\.{name} must be positive"):
|
||||
load_deploy_config(deploy_path)
|
||||
@@ -0,0 +1,14 @@
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.intermediate import get_stream_request_key
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_get_stream_request_key_requires_stable_identifier():
|
||||
with pytest.raises(ValueError, match="stable request id"):
|
||||
get_stream_request_key({"ids": {"tts": [1, 2, 3]}})
|
||||
|
||||
|
||||
def test_get_stream_request_key_accepts_global_request_id():
|
||||
assert get_stream_request_key({"global_request_id": ["duplex-sid-stage0"]}) == "duplex-sid-stage0"
|
||||
@@ -0,0 +1,224 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import DuplexInputMode
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_session import DuplexSessionRuntimeManager
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import (
|
||||
DuplexLeaseActivity,
|
||||
DuplexLeaseConfig,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FakeMonotonicClock:
|
||||
def __init__(self, value: float) -> None:
|
||||
self.value = value
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.value += seconds
|
||||
|
||||
|
||||
def _lease_config(*, idle_ttl_s: float | None = 300.0) -> DuplexLeaseConfig:
|
||||
return DuplexLeaseConfig(
|
||||
idle_ttl_s=idle_ttl_s,
|
||||
disconnect_grace_s=30.0,
|
||||
)
|
||||
|
||||
|
||||
def test_open_touch_detach_and_idle_expiry_use_monotonic_time() -> None:
|
||||
clock = FakeMonotonicClock(100.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
fence = DuplexFence("sid-expiry")
|
||||
session = manager.open_session(fence, lease_config=_lease_config())
|
||||
|
||||
assert session.lease.last_activity == 100.0
|
||||
clock.advance(10.0)
|
||||
session.touch(fence, DuplexLeaseActivity.HEARTBEAT)
|
||||
session.detach(fence)
|
||||
clock.advance(29.0)
|
||||
assert session.lease.disconnect_grace_expired(clock()) is False
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
clock.advance(272.0)
|
||||
expired = manager.collect_expired()
|
||||
|
||||
assert [item.session_id for item in expired] == ["sid-expiry"]
|
||||
assert expired[0].reason == "disconnect_grace_expired"
|
||||
assert manager.get("sid-expiry") is session
|
||||
manager.finalize_close_session(session)
|
||||
assert manager.get("sid-expiry") is None
|
||||
|
||||
|
||||
def test_detach_grace_and_resume_advance_token_independent_lease_generation() -> None:
|
||||
clock = FakeMonotonicClock(10.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
fence = DuplexFence("sid-resume")
|
||||
session = manager.open_session(fence, lease_config=_lease_config())
|
||||
|
||||
session.detach(fence)
|
||||
clock.advance(29.0)
|
||||
assert session.lease.disconnect_grace_expired(clock()) is False
|
||||
clock.advance(2.0)
|
||||
assert session.lease.disconnect_grace_expired(clock()) is True
|
||||
|
||||
generation = session.resume(fence, expected_lease_generation=0)
|
||||
|
||||
assert generation == 1
|
||||
assert session.lease.detached_at is None
|
||||
assert session.lease.disconnect_grace_expired(clock()) is False
|
||||
with pytest.raises(ValueError, match="lease generation mismatch"):
|
||||
session.resume(fence, expected_lease_generation=0)
|
||||
|
||||
|
||||
def test_active_operation_prevents_mid_transaction_expiry() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
fence = DuplexFence("sid-operation")
|
||||
session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=5.0))
|
||||
|
||||
session.begin_operation(fence, "append-1")
|
||||
clock.advance(10.0)
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
session.end_operation(fence, "append-1")
|
||||
assert manager.collect_expired() == []
|
||||
clock.advance(6.0)
|
||||
assert [item.session_id for item in manager.collect_expired()] == ["sid-operation"]
|
||||
|
||||
|
||||
def test_sessions_have_independent_activity_deadlines_and_resources() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
fence_a = DuplexFence("sid-a")
|
||||
fence_b = DuplexFence("sid-b")
|
||||
session_a = manager.open_session(fence_a, lease_config=_lease_config(idle_ttl_s=10.0))
|
||||
session_b = manager.open_session(fence_b, lease_config=_lease_config(idle_ttl_s=10.0))
|
||||
session_a.reserve_stage_request(0, "req-a-reserved", fence=fence_a)
|
||||
session_a.bind_stage_request(1, "req-a-submitted", fence=fence_a)
|
||||
session_b.bind_stage_request(0, "req-b", fence=fence_b)
|
||||
|
||||
clock.advance(6.0)
|
||||
session_b.touch(fence_b, DuplexLeaseActivity.MODEL_OUTPUT)
|
||||
clock.advance(5.0)
|
||||
expired = manager.collect_expired()
|
||||
|
||||
assert len(expired) == 1
|
||||
assert expired[0].session_id == "sid-a"
|
||||
assert expired[0].reserved_request_ids == ("req-a-reserved",)
|
||||
assert expired[0].submitted_request_ids == ("req-a-submitted",)
|
||||
assert manager.get("sid-b") is session_b
|
||||
assert session_b.resource_request_ids() == ["req-b"]
|
||||
|
||||
|
||||
def test_close_and_reaper_select_exactly_one_terminal_transition() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
fence = DuplexFence("sid-race")
|
||||
manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=1.0))
|
||||
clock.advance(2.0)
|
||||
|
||||
assert len(manager.collect_expired()) == 1
|
||||
assert manager.close_session(fence, reason="explicit_close") is None
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
fence_2 = DuplexFence("sid-race-2")
|
||||
manager.open_session(fence_2, lease_config=_lease_config(idle_ttl_s=1.0))
|
||||
assert manager.close_session(fence_2, reason="explicit_close") is not None
|
||||
clock.advance(2.0)
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
|
||||
def test_stale_fence_cannot_touch_detach_or_resume_lease() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
current = DuplexFence("sid-fence", epoch=1)
|
||||
stale = DuplexFence("sid-fence", epoch=0)
|
||||
session = manager.open_session(current, lease_config=_lease_config())
|
||||
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
session.touch(stale, DuplexLeaseActivity.APPEND)
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
session.detach(stale)
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
session.resume(stale, expected_lease_generation=0)
|
||||
|
||||
|
||||
def test_disabled_idle_expiry_never_collects_session() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
manager.open_session(DuplexFence("sid-no-expiry"), lease_config=_lease_config(idle_ttl_s=None))
|
||||
|
||||
clock.advance(1_000_000.0)
|
||||
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
|
||||
def test_detached_session_expires_at_disconnect_grace_when_idle_ttl_is_disabled() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
fence = DuplexFence("sid-disconnect-grace")
|
||||
session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=None))
|
||||
|
||||
session.detach(fence)
|
||||
clock.advance(29.0)
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
clock.advance(1.0)
|
||||
expired = manager.collect_expired()
|
||||
|
||||
assert [item.session_id for item in expired] == [fence.session_id]
|
||||
assert expired[0].reason == "disconnect_grace_expired"
|
||||
|
||||
|
||||
def test_runtime_manager_enforces_server_owned_session_admission() -> None:
|
||||
manager = DuplexSessionRuntimeManager(max_sessions=2)
|
||||
manager.open_session(DuplexFence("sid-admission-a"))
|
||||
manager.open_session(DuplexFence("sid-admission-b"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="duplex_session_capacity_exhausted"):
|
||||
manager.open_session(DuplexFence("sid-admission-c"))
|
||||
|
||||
|
||||
def test_completed_append_cache_is_bounded() -> None:
|
||||
manager = DuplexSessionRuntimeManager(completed_append_limit=2)
|
||||
fence = DuplexFence("sid-completed-cache")
|
||||
session = manager.open_session(fence)
|
||||
|
||||
for index in range(3):
|
||||
session.record_completed_append(
|
||||
f"operation-{index}",
|
||||
fence=fence,
|
||||
mode=DuplexInputMode.TURN_COMMIT_ONLY,
|
||||
final=True,
|
||||
stage_results=[{"index": index}],
|
||||
)
|
||||
|
||||
assert list(session.completed_appends) == ["operation-1", "operation-2"]
|
||||
|
||||
session.accept_fence(DuplexFence(fence.session_id, epoch=1))
|
||||
assert session.completed_appends == {}
|
||||
|
||||
|
||||
def test_lease_config_and_expiry_record_are_immutable() -> None:
|
||||
config = _lease_config()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
config.idle_ttl_s = 1.0 # type: ignore[misc]
|
||||
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
manager.open_session(DuplexFence("sid-record"), lease_config=_lease_config(idle_ttl_s=1.0))
|
||||
clock.advance(2.0)
|
||||
record = manager.collect_expired()[0]
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
record.reason = "changed" # type: ignore[misc]
|
||||
@@ -0,0 +1,480 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine import duplex_runtime
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
duplex_data_plane_request_info,
|
||||
duplex_resource_request_belongs_to_session,
|
||||
duplex_resource_request_id,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
|
||||
DuplexInputMode,
|
||||
DuplexOutputAction,
|
||||
DuplexOutputDecision,
|
||||
DuplexRuntimeCapabilities,
|
||||
DuplexSessionRuntimeManager,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.runtime import (
|
||||
MiniCPMO45DuplexRuntimeExtension,
|
||||
build_duplex_data_plane_prompt,
|
||||
duplex_scheduler_token_budget,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_duplex_fence_is_immutable():
|
||||
fence = DuplexFence("session")
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
fence.epoch = 1 # type: ignore[misc]
|
||||
|
||||
assert not hasattr(fence, "__dict__")
|
||||
|
||||
|
||||
def test_duplex_runtime_tracks_stage_bindings_and_barge_in_epoch():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
session = manager.open_session(
|
||||
DuplexFence("sid-1"),
|
||||
capabilities=DuplexRuntimeCapabilities(
|
||||
input_modes={DuplexInputMode.APPEND_TOKENS},
|
||||
),
|
||||
)
|
||||
session.bind_stage_request(stage_id=0, request_id="req-stage0", fence=session.fence)
|
||||
session.bind_stage_request(stage_id=1, request_id="req-stage1", fence=session.fence)
|
||||
|
||||
update = session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence)
|
||||
next_fence = DuplexFence("sid-1", epoch=1)
|
||||
stale_request_ids = session.release_fence(session.fence)
|
||||
session.accept_fence(next_fence)
|
||||
|
||||
assert update.seq == 1
|
||||
assert session.fence == next_fence
|
||||
assert stale_request_ids == ["req-stage0", "req-stage1"]
|
||||
assert session.stage_bindings == {}
|
||||
|
||||
|
||||
def test_duplex_runtime_tracks_same_request_id_for_each_pipeline_stage():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
fence = DuplexFence("sid-shared-pipeline-request")
|
||||
session = manager.open_session(fence)
|
||||
|
||||
session.reserve_stage_request(0, "req-shared", fence=fence)
|
||||
session.bind_stage_request(0, "req-shared", fence=fence)
|
||||
session.bind_stage_request(1, "req-shared", fence=fence)
|
||||
|
||||
assert session.stage_bindings[0].request_id == "req-shared"
|
||||
assert session.stage_bindings[1].request_id == "req-shared"
|
||||
assert session.resource_request_ids() == ["req-shared"]
|
||||
assert session.input_seq == 0
|
||||
|
||||
|
||||
def test_duplex_runtime_extension_validation_rejects_missing_methods():
|
||||
class IncompleteExtension:
|
||||
def configure_sampling_params(self, *, runtime_config, defaults):
|
||||
del runtime_config
|
||||
return defaults
|
||||
|
||||
with pytest.raises(TypeError, match="plan_append"):
|
||||
duplex_runtime.validate_duplex_runtime_extension(IncompleteExtension())
|
||||
|
||||
|
||||
def test_duplex_runtime_extension_validation_rejects_stage_count_mismatch():
|
||||
class WrongStageCountExtension(MiniCPMO45DuplexRuntimeExtension):
|
||||
def configure_sampling_params(self, *, runtime_config, defaults):
|
||||
del runtime_config
|
||||
return defaults[:1]
|
||||
|
||||
with pytest.raises(ValueError, match="one sampling parameter per stage"):
|
||||
duplex_runtime.validate_duplex_runtime_extension(
|
||||
WrongStageCountExtension(),
|
||||
sampling_defaults=(SamplingParams(), SamplingParams()),
|
||||
)
|
||||
|
||||
|
||||
def test_duplex_runtime_extension_validation_rejects_sampling_type_mismatch():
|
||||
class WrongSamplingTypeExtension(MiniCPMO45DuplexRuntimeExtension):
|
||||
def configure_sampling_params(self, *, runtime_config, defaults):
|
||||
del runtime_config
|
||||
return tuple(object() for _ in defaults)
|
||||
|
||||
with pytest.raises(TypeError, match="sampling parameter type"):
|
||||
duplex_runtime.validate_duplex_runtime_extension(
|
||||
WrongSamplingTypeExtension(),
|
||||
sampling_defaults=(SamplingParams(), SamplingParams()),
|
||||
)
|
||||
|
||||
|
||||
def test_duplex_output_decision_metadata_is_immutable():
|
||||
decision = DuplexOutputDecision(
|
||||
action=DuplexOutputAction.DIRECT_RESPONSE,
|
||||
metadata={"model_listen": True},
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
decision.metadata["model_listen"] = False
|
||||
|
||||
|
||||
def _decide_minicpmo_output(
|
||||
output: object,
|
||||
*,
|
||||
segment_token_ids: tuple[int, ...] = (),
|
||||
segment_output_metadata: dict | None = None,
|
||||
):
|
||||
return MiniCPMO45DuplexRuntimeExtension().decide_output(
|
||||
stage_id=0,
|
||||
final_stage_id=1,
|
||||
segment_finished=True,
|
||||
segment_token_ids=segment_token_ids,
|
||||
segment_output_metadata=segment_output_metadata or {},
|
||||
output=output,
|
||||
)
|
||||
|
||||
|
||||
def test_minicpmo_extension_owns_stage_sampling_overrides():
|
||||
defaults = (
|
||||
SamplingParams(max_tokens=4),
|
||||
SamplingParams(max_tokens=8),
|
||||
)
|
||||
|
||||
configured = MiniCPMO45DuplexRuntimeExtension().configure_sampling_params(
|
||||
runtime_config={
|
||||
"duplex_stage_max_tokens": {"0": 20},
|
||||
"duplex_stage_sampling_params": {"1": {"stop_token_ids": [151645]}},
|
||||
},
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
assert configured[0].max_tokens == 20
|
||||
assert configured[1].stop_token_ids == [151645]
|
||||
assert defaults[0].max_tokens == 4
|
||||
assert 151645 not in (defaults[1].stop_token_ids or [])
|
||||
|
||||
|
||||
def test_minicpmo_output_decision_uses_raw_streaming_token_snapshot():
|
||||
decision = _decide_minicpmo_output(
|
||||
SimpleNamespace(outputs=[SimpleNamespace()]),
|
||||
segment_token_ids=(151705,),
|
||||
segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}},
|
||||
)
|
||||
|
||||
assert decision is not None
|
||||
assert decision.action is DuplexOutputAction.DIRECT_RESPONSE
|
||||
assert decision.metadata["duplex_native_decision"] == "listen"
|
||||
assert decision.metadata["model_listen"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"])
|
||||
def test_minicpmo_output_decision_ignores_output_level_token_history(attr):
|
||||
output = SimpleNamespace(
|
||||
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
|
||||
outputs=[SimpleNamespace()],
|
||||
**{attr: [42, 151705]},
|
||||
)
|
||||
|
||||
assert _decide_minicpmo_output(output) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"])
|
||||
def test_minicpmo_output_decision_uses_completion_token_ids(attr):
|
||||
output = SimpleNamespace(
|
||||
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
|
||||
outputs=[SimpleNamespace(**{attr: [42, 151705]})],
|
||||
)
|
||||
|
||||
assert _decide_minicpmo_output(output) is not None
|
||||
|
||||
|
||||
def test_minicpmo_output_decision_uses_completion_stop_reason():
|
||||
output = SimpleNamespace(
|
||||
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
|
||||
outputs=[SimpleNamespace(stop_reason=151705)],
|
||||
)
|
||||
|
||||
assert _decide_minicpmo_output(output) is not None
|
||||
|
||||
|
||||
def test_duplex_runtime_cancel_fence_rejects_late_append_and_accepts_next_epoch():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
cancelled_fence = DuplexFence("sid-cancel-race")
|
||||
next_fence = DuplexFence("sid-cancel-race", epoch=1)
|
||||
session = manager.open_session(
|
||||
cancelled_fence,
|
||||
capabilities=DuplexRuntimeCapabilities(
|
||||
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
|
||||
),
|
||||
)
|
||||
session.bind_stage_request(
|
||||
stage_id=0,
|
||||
request_id="req-cancelled",
|
||||
fence=cancelled_fence,
|
||||
)
|
||||
|
||||
stale_request_ids = session.cancel_fence(cancelled_fence, next_fence)
|
||||
|
||||
assert stale_request_ids == ["req-cancelled"]
|
||||
assert session.fence == next_fence
|
||||
assert session.stage_bindings == {}
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
session.append_input(
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
fence=cancelled_fence,
|
||||
)
|
||||
update = session.append_input(
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
fence=next_fence,
|
||||
)
|
||||
assert update.seq == 1
|
||||
|
||||
|
||||
def test_duplex_runtime_stale_close_preserves_live_session_and_bindings():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
current_fence = DuplexFence("sid-stale-close", epoch=1)
|
||||
session = manager.open_session(
|
||||
current_fence,
|
||||
capabilities=DuplexRuntimeCapabilities(
|
||||
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
|
||||
),
|
||||
)
|
||||
session.bind_stage_request(0, "req-live", fence=current_fence)
|
||||
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
manager.close_session(DuplexFence("sid-stale-close"))
|
||||
|
||||
assert manager.get("sid-stale-close") is session
|
||||
assert session.stage_request_ids() == ["req-live"]
|
||||
|
||||
|
||||
def test_duplex_runtime_reopen_rejects_late_append_from_old_incarnation():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
old_fence = DuplexFence("sid-reopen", incarnation=0)
|
||||
old_session = manager.open_session(
|
||||
old_fence,
|
||||
capabilities=DuplexRuntimeCapabilities(
|
||||
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
|
||||
),
|
||||
)
|
||||
manager.close_session(old_fence)
|
||||
|
||||
new_fence = DuplexFence("sid-reopen", incarnation=1)
|
||||
new_session = manager.open_session(
|
||||
new_fence,
|
||||
capabilities=old_session.capabilities,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
new_session.append_input(
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
fence=old_fence,
|
||||
)
|
||||
assert (
|
||||
new_session.append_input(
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
fence=new_fence,
|
||||
).seq
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_duplex_prompt_expands_incarnation_metadata():
|
||||
fence = DuplexFence("sid-incarnation", incarnation=3)
|
||||
|
||||
prompt = build_duplex_data_plane_prompt(
|
||||
request_id=duplex_resource_request_id(fence, "stage0"),
|
||||
fence=fence,
|
||||
session_config={},
|
||||
runtime_config={},
|
||||
seq=1,
|
||||
turn_seq=1,
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
payload={"is_speech": True},
|
||||
final=False,
|
||||
)
|
||||
|
||||
assert prompt["model_intermediate_buffer"]["duplex"]["incarnation"] == 3
|
||||
|
||||
|
||||
def test_duplex_runtime_tracks_turn_local_append_sequence():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
session = manager.open_session(
|
||||
DuplexFence("sid-turn-seq"),
|
||||
capabilities=DuplexRuntimeCapabilities(
|
||||
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
|
||||
),
|
||||
)
|
||||
|
||||
first = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence)
|
||||
second = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence)
|
||||
next_turn = DuplexFence("sid-turn-seq", turn_id=1, response_seq=1)
|
||||
third = session.append_input(
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
fence=next_turn,
|
||||
)
|
||||
fourth = session.append_input(
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
fence=next_turn,
|
||||
)
|
||||
|
||||
assert [first.seq, second.seq, third.seq, fourth.seq] == [1, 2, 3, 4]
|
||||
assert [first.turn_seq, second.turn_seq, third.turn_seq, fourth.turn_seq] == [1, 2, 1, 2]
|
||||
assert [first.turn_id, second.turn_id, third.turn_id, fourth.turn_id] == [0, 0, 1, 1]
|
||||
|
||||
|
||||
def test_duplex_runtime_rejects_unsupported_append_mode():
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
session = manager.open_session(
|
||||
DuplexFence("sid-2"),
|
||||
capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.TURN_COMMIT_ONLY}),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence)
|
||||
|
||||
|
||||
def test_duplex_data_plane_request_info_extracts_structured_stage_result():
|
||||
request_id, response_stage_id = duplex_data_plane_request_info(
|
||||
{
|
||||
"stage_results": [
|
||||
{"result": {"supported": True}},
|
||||
{
|
||||
"result": {
|
||||
"data_plane_append": True,
|
||||
"request_id": "duplex-sid-e0-stage0-s1",
|
||||
"response_stage_id": 1,
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert request_id == "duplex-sid-e0-stage0-s1"
|
||||
assert response_stage_id == 1
|
||||
|
||||
|
||||
def test_duplex_data_plane_request_info_rejects_missing_request_id():
|
||||
assert duplex_data_plane_request_info(
|
||||
{
|
||||
"stage_results": [
|
||||
{
|
||||
"result": {
|
||||
"data_plane_append": True,
|
||||
"request_id": "",
|
||||
"response_stage_id": 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
) == (None, None)
|
||||
|
||||
|
||||
def test_duplex_scheduler_token_budget_estimates_pcm_slots():
|
||||
assert (
|
||||
duplex_scheduler_token_budget(
|
||||
{
|
||||
"audio": "AAAAAA==",
|
||||
"format": "pcm_f32le",
|
||||
"sample_rate_hz": 16000,
|
||||
}
|
||||
)
|
||||
== 16
|
||||
)
|
||||
|
||||
|
||||
def test_duplex_scheduler_token_budget_ignores_client_budget_fields():
|
||||
assert (
|
||||
duplex_scheduler_token_budget(
|
||||
{
|
||||
"audio": "AAAAAA==",
|
||||
"format": "pcm_f32le",
|
||||
"duplex_num_input_tokens": 999,
|
||||
"num_input_tokens": 999,
|
||||
}
|
||||
)
|
||||
== 16
|
||||
)
|
||||
|
||||
|
||||
def test_resource_state_rejects_fence_regression_and_requires_explicit_fence():
|
||||
current = DuplexFence("sid", epoch=2, turn_id=3, response_seq=4)
|
||||
manager = DuplexSessionRuntimeManager()
|
||||
session = manager.open_session(
|
||||
current,
|
||||
capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}),
|
||||
)
|
||||
|
||||
for stale in (
|
||||
DuplexFence("sid", epoch=1, turn_id=99, response_seq=99),
|
||||
DuplexFence("sid", epoch=2, turn_id=2, response_seq=4),
|
||||
DuplexFence("sid", epoch=2, turn_id=3, response_seq=3),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="fence mismatch"):
|
||||
session.accept_fence(stale)
|
||||
assert session.fence == current
|
||||
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
session.bind_stage_request(0, "request")
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK)
|
||||
with pytest.raises(TypeError, match="DuplexFence"):
|
||||
manager.open_session("legacy-session")
|
||||
with pytest.raises(TypeError, match="DuplexFence"):
|
||||
manager.close_session("legacy-session")
|
||||
|
||||
|
||||
def test_resource_request_id_is_derived_from_fence_and_role():
|
||||
fence = DuplexFence("sid-with-dashes", epoch=7, turn_id=11, response_seq=13)
|
||||
|
||||
assert duplex_resource_request_id(fence, "stage0") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage0"
|
||||
assert duplex_resource_request_id(fence, "stage1") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage1"
|
||||
|
||||
|
||||
def test_resource_request_id_codec_separates_session_id_from_incarnation():
|
||||
embedded_incarnation = duplex_resource_request_id(
|
||||
DuplexFence("foo-i1", incarnation=0),
|
||||
"stage0",
|
||||
)
|
||||
actual_incarnation = duplex_resource_request_id(
|
||||
DuplexFence("foo", incarnation=1),
|
||||
"stage0",
|
||||
)
|
||||
|
||||
assert embedded_incarnation != actual_incarnation
|
||||
|
||||
|
||||
def test_resource_request_id_session_membership_uses_encoded_identity():
|
||||
request_id = duplex_resource_request_id(
|
||||
DuplexFence("sid-with-dashes", incarnation=2, epoch=7),
|
||||
"stage0",
|
||||
)
|
||||
|
||||
assert duplex_resource_request_belongs_to_session(request_id, "sid-with-dashes") is True
|
||||
assert duplex_resource_request_belongs_to_session(request_id, "sid") is False
|
||||
assert duplex_resource_request_belongs_to_session("duplex-s.invalid.i.x.e.7.r.stage0", "sid") is False
|
||||
|
||||
|
||||
def test_placeholder_budget_is_planned_inside_omni_engine_boundary():
|
||||
fence = DuplexFence("sid", turn_id=1, response_seq=1)
|
||||
prompt = build_duplex_data_plane_prompt(
|
||||
request_id=duplex_resource_request_id(fence, "stage0"),
|
||||
fence=fence,
|
||||
session_config={},
|
||||
runtime_config={},
|
||||
seq=2,
|
||||
turn_seq=1,
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
payload={
|
||||
"audio": "AAAAAA==",
|
||||
"format": "pcm_f32le",
|
||||
"duplex_num_input_tokens": 999,
|
||||
"num_input_tokens": 999,
|
||||
},
|
||||
final=False,
|
||||
)
|
||||
|
||||
assert len(prompt["prompt_token_ids"]) == 16
|
||||
assert prompt["model_intermediate_buffer"]["duplex"]["fence"] == fence
|
||||
assert prompt["model_intermediate_buffer"]["duplex"]["scheduler_token_budget"] == 16
|
||||
Whitespace-only changes.
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.commit_policy import (
|
||||
CommitAction,
|
||||
CommitSnapshot,
|
||||
decide_commit_action,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "snapshot", "expected"),
|
||||
[
|
||||
(
|
||||
"first idle commit",
|
||||
CommitSnapshot(
|
||||
auto_responds=True,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=0,
|
||||
native_response_in_progress=False,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.START_AUTO_RESPONSE,
|
||||
),
|
||||
(
|
||||
"native auto-response remains model-owned during playback",
|
||||
CommitSnapshot(
|
||||
auto_responds=True,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=800,
|
||||
native_response_in_progress=True,
|
||||
playback_active=True,
|
||||
),
|
||||
CommitAction.START_AUTO_RESPONSE,
|
||||
),
|
||||
(
|
||||
"native auto-response commit without speech",
|
||||
CommitSnapshot(
|
||||
auto_responds=True,
|
||||
speech_since_commit=False,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=0,
|
||||
native_response_in_progress=True,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.COMMIT_ONLY,
|
||||
),
|
||||
(
|
||||
"non-auto response overlap",
|
||||
CommitSnapshot(
|
||||
auto_responds=False,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=800,
|
||||
native_response_in_progress=True,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.DEFER_ACTIVE_RESPONSE,
|
||||
),
|
||||
(
|
||||
"non-auto playback overlap",
|
||||
CommitSnapshot(
|
||||
auto_responds=False,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=800,
|
||||
native_response_in_progress=False,
|
||||
playback_active=True,
|
||||
),
|
||||
CommitAction.DEFER_ACTIVE_RESPONSE,
|
||||
),
|
||||
(
|
||||
"non-auto commit without overlap",
|
||||
CommitSnapshot(
|
||||
auto_responds=False,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=0,
|
||||
native_response_in_progress=False,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.COMMIT_ONLY,
|
||||
),
|
||||
],
|
||||
ids=lambda value: value if isinstance(value, str) else None,
|
||||
)
|
||||
def test_decide_commit_action_covers_realtime_lifecycle_sequences(
|
||||
case: str,
|
||||
snapshot: CommitSnapshot,
|
||||
expected: CommitAction,
|
||||
) -> None:
|
||||
del case
|
||||
|
||||
assert decide_commit_action(snapshot) is expected
|
||||
@@ -0,0 +1,131 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import base64
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.input import MiniCPMO45PcmAppendBuffer
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def pcm_payload(samples: int, *, speech: bool = True) -> dict[str, object]:
|
||||
audio = np.ones(samples, dtype=np.float32).tobytes()
|
||||
return {
|
||||
"type": "audio",
|
||||
"audio": base64.b64encode(audio).decode("ascii"),
|
||||
"format": "pcm_f32le",
|
||||
"sample_rate_hz": 16_000,
|
||||
"is_speech": speech,
|
||||
}
|
||||
|
||||
|
||||
def test_commit_does_not_add_silence_after_incremental_audio_was_drained():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
|
||||
emitted = buffer.append(pcm_payload(16_000), chunk_period_ms=1_000)
|
||||
committed = buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
assert emitted is not None
|
||||
assert not buffer.has_pending()
|
||||
assert committed is None
|
||||
|
||||
|
||||
def test_append_emits_one_model_unit_when_multiple_units_are_buffered():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
|
||||
emitted = buffer.append(pcm_payload(32_000), chunk_period_ms=1_000)
|
||||
|
||||
assert emitted is not None
|
||||
assert len(base64.b64decode(emitted["audio"])) == 16_000 * 4
|
||||
assert buffer.pending_byte_count == 16_000 * 4
|
||||
|
||||
|
||||
def test_commit_without_speech_does_not_synthesize_terminal_audio():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000)
|
||||
|
||||
committed = buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
assert committed is None
|
||||
|
||||
|
||||
def test_commit_resets_cumulative_turn_accounting():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
buffer.append(pcm_payload(16_000), chunk_period_ms=1_000)
|
||||
buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
empty = buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
assert empty is None
|
||||
|
||||
|
||||
def test_pcm_append_reservation_rollback_restores_emitted_audio():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
original = pcm_payload(16_000)
|
||||
|
||||
reservation = buffer.prepare_append(
|
||||
original,
|
||||
operation_id="append-1",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert reservation is not None
|
||||
assert not buffer.has_pending()
|
||||
reservation.rollback()
|
||||
assert buffer.has_pending()
|
||||
|
||||
retried = buffer.flush(chunk_period_ms=1_000)
|
||||
assert retried is not None
|
||||
assert base64.b64decode(retried["audio"]) == base64.b64decode(original["audio"])
|
||||
|
||||
|
||||
def test_pcm_commit_keeps_prior_append_reservation_active():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
append_reservation = buffer.prepare_append(
|
||||
pcm_payload(16_000),
|
||||
operation_id="append-before-commit",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
commit_reservation = buffer.prepare_commit(
|
||||
operation_id="commit-after-append",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert append_reservation is not None
|
||||
assert append_reservation.active
|
||||
assert commit_reservation.payload is None
|
||||
append_reservation.commit()
|
||||
commit_reservation.commit()
|
||||
|
||||
|
||||
def test_pcm_commit_reservation_rollback_restores_residual_audio():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
original = pcm_payload(8_000)
|
||||
assert (
|
||||
buffer.prepare_append(
|
||||
original,
|
||||
operation_id="buffer-half-chunk",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
reservation = buffer.prepare_commit(
|
||||
operation_id="final-half-chunk",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert reservation.payload is not None
|
||||
assert reservation.payload["final"] is True
|
||||
reservation.rollback()
|
||||
|
||||
retried = buffer.prepare_commit(
|
||||
operation_id="retry-final-half-chunk",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
assert retried.payload is not None
|
||||
assert base64.b64decode(retried.payload["audio"]) == (base64.b64decode(original["audio"]) + b"\x00" * (8_000 * 4))
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import base64
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.audio import convert_input_audio_with_rate
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_pcm16_input_conversion_is_pure_and_reports_target_rate():
|
||||
pcm16 = np.array([-32768, 0, 32767], dtype="<i2").tobytes()
|
||||
encoded = base64.b64encode(pcm16).decode("ascii")
|
||||
|
||||
converted, fmt, sample_rate = convert_input_audio_with_rate(
|
||||
encoded,
|
||||
"pcm16",
|
||||
sample_rate_hz=16_000,
|
||||
)
|
||||
|
||||
assert encoded == base64.b64encode(pcm16).decode("ascii")
|
||||
assert fmt == "pcm_f32le"
|
||||
assert sample_rate == 16_000
|
||||
samples = np.frombuffer(base64.b64decode(converted), dtype="<f4")
|
||||
assert np.allclose(samples, [-1.0, 0.0, 32767 / 32768])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sample_rate_hz", [8_000, 192_000])
|
||||
def test_pcm16_input_conversion_accepts_supported_sample_rate_boundaries(sample_rate_hz: int):
|
||||
encoded = base64.b64encode(np.zeros(8, dtype="<i2").tobytes()).decode("ascii")
|
||||
|
||||
_, fmt, converted_rate = convert_input_audio_with_rate(
|
||||
encoded,
|
||||
"pcm16",
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
)
|
||||
|
||||
assert fmt == "pcm_f32le"
|
||||
assert converted_rate == 16_000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sample_rate_hz", [7_999, 192_001, 10**1000])
|
||||
def test_pcm16_input_conversion_rejects_sample_rates_outside_supported_range(sample_rate_hz: int):
|
||||
encoded = base64.b64encode(np.zeros(8, dtype="<i2").tobytes()).decode("ascii")
|
||||
|
||||
with pytest.raises(ValueError, match="sample_rate_hz"):
|
||||
convert_input_audio_with_rate(
|
||||
encoded,
|
||||
"pcm16",
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import (
|
||||
validate_serving_runtime_adapter,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_name",
|
||||
[
|
||||
"vllm_omni.experimental.fullduplex.openai.session_runner",
|
||||
"vllm_omni.experimental.fullduplex.openai.runtime_bridge",
|
||||
"vllm_omni.experimental.fullduplex.openai.serving",
|
||||
],
|
||||
)
|
||||
def test_generic_openai_runtime_import_does_not_load_minicpmo45(module_name: str) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
script = f"""
|
||||
import sys
|
||||
|
||||
import {module_name}
|
||||
|
||||
loaded = sorted(
|
||||
name
|
||||
for name in sys.modules
|
||||
if name == "vllm_omni.experimental.fullduplex.minicpmo45"
|
||||
or name.startswith("vllm_omni.experimental.fullduplex.minicpmo45.")
|
||||
)
|
||||
if loaded:
|
||||
raise SystemExit("generic OpenAI runtime loaded MiniCPM-o: " + ", ".join(loaded))
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_generic_handler_requires_explicit_serving_runtime_adapter() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
script = """
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler
|
||||
|
||||
chat_service = SimpleNamespace(engine_client=SimpleNamespace())
|
||||
try:
|
||||
OmniDuplexSessionHandler(chat_service=chat_service)
|
||||
except ValueError as exc:
|
||||
if "serving runtime adapter" not in str(exc).lower():
|
||||
raise
|
||||
else:
|
||||
raise SystemExit("generic handler silently selected a model serving adapter")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def _valid_runtime_adapter() -> SimpleNamespace:
|
||||
data_plane = SimpleNamespace(
|
||||
begin_request=lambda request_id: None,
|
||||
is_terminal=lambda request_id: False,
|
||||
mark_terminal=lambda request_id: None,
|
||||
close_stream=lambda request_id: None,
|
||||
close_session=lambda session_id, **kwargs: None,
|
||||
project=lambda result, **kwargs: (),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
adapter_id="test-adapter",
|
||||
session_states={},
|
||||
data_plane=data_plane,
|
||||
clean_response_done_prefix="clean",
|
||||
interrupted_tts_prefix="interrupted",
|
||||
private_runtime_config_keys=frozenset({"private_key"}),
|
||||
create_session_state=lambda: object(),
|
||||
session_state=lambda session_id: object(),
|
||||
remove_session_state=lambda session_id: None,
|
||||
is_enabled=lambda config: True,
|
||||
capabilities=lambda **kwargs: object(),
|
||||
validate_client_extra_body=lambda extra_body: None,
|
||||
prepare_runtime_config=lambda config, **kwargs: {},
|
||||
runtime_config_for_update=lambda config, current: {},
|
||||
data_plane_context=lambda **kwargs: object(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attribute", "invalid_value"),
|
||||
[
|
||||
("session_states", None),
|
||||
("clean_response_done_prefix", None),
|
||||
("interrupted_tts_prefix", None),
|
||||
("private_runtime_config_keys", {"private_key"}),
|
||||
],
|
||||
)
|
||||
def test_runtime_adapter_validator_rejects_invalid_protocol_attributes(
|
||||
attribute: str,
|
||||
invalid_value: object,
|
||||
) -> None:
|
||||
adapter = _valid_runtime_adapter()
|
||||
setattr(adapter, attribute, invalid_value)
|
||||
|
||||
with pytest.raises(TypeError, match=attribute):
|
||||
validate_serving_runtime_adapter(adapter)
|
||||
|
||||
|
||||
def test_runtime_adapter_validator_rejects_incomplete_data_plane() -> None:
|
||||
adapter = _valid_runtime_adapter()
|
||||
adapter.data_plane.close_session = None
|
||||
|
||||
with pytest.raises(TypeError, match="data_plane.*close_session"):
|
||||
validate_serving_runtime_adapter(adapter)
|
||||
@@ -0,0 +1,145 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.websocket import (
|
||||
DuplexWebSocketActor,
|
||||
normalize_duplex_input_event,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_json(self, payload: dict[str, object]) -> None:
|
||||
self.sent.append(dict(payload))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected"),
|
||||
[
|
||||
({"type": "signal_turn", "event": "barge_in"}, {"type": "turn.signal", "event": "barge_in"}),
|
||||
({"type": "close_session"}, {"type": "session.close"}),
|
||||
({"type": "audio.playback_ack", "played_ms": 1}, {"type": "playback.ack", "played_ms": 1}),
|
||||
({"type": "input_text.append", "text": "a"}, {"type": "input.text.append", "text": "a"}),
|
||||
({"type": "push_text", "text": "b"}, {"type": "input.text.append", "text": "b"}),
|
||||
(
|
||||
{"type": "input.audio.append", "audio": "wav"},
|
||||
{"type": "input_audio_buffer.append", "audio": "wav", "format": "wav"},
|
||||
),
|
||||
(
|
||||
{"type": "push_chunk", "audio": "wav", "format": "pcm_f32le"},
|
||||
{"type": "input_audio_buffer.append", "audio": "wav", "format": "pcm_f32le"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_input_aliases_normalize_once_at_mailbox_boundary(event, expected):
|
||||
assert normalize_duplex_input_event(event) == expected
|
||||
|
||||
|
||||
def test_actor_uses_one_fifo_mailbox_for_inbound_events():
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
|
||||
assert isinstance(actor.mailbox, asyncio.Queue)
|
||||
assert not isinstance(actor.mailbox, asyncio.PriorityQueue)
|
||||
assert not hasattr(actor, "control_queue")
|
||||
assert not hasattr(actor, "input_queue")
|
||||
assert not hasattr(actor, "event_queue")
|
||||
assert not hasattr(actor, "lifecycle_state")
|
||||
assert not hasattr(actor, "last_response_id")
|
||||
assert not hasattr(actor, "overlap_speech_ms")
|
||||
assert not hasattr(actor, "transition")
|
||||
assert not hasattr(actor, "output_generation_in_flight")
|
||||
assert not hasattr(actor, "session")
|
||||
assert not hasattr(actor, "runtime_opened")
|
||||
assert not hasattr(actor, "runtime_closed")
|
||||
assert not hasattr(actor, "drain_input_queue")
|
||||
assert not hasattr(actor, "control_events_seen")
|
||||
assert not hasattr(actor, "input_events_seen")
|
||||
assert not hasattr(actor, "cancel_count")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_event_does_not_overtake_earlier_audio_input():
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
|
||||
await actor.enqueue_event({"type": "response.cancel"})
|
||||
|
||||
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
|
||||
assert await actor.next_event() == {"type": "response.cancel"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("event_type", ["input.cancel", "session.close", "close_session"])
|
||||
async def test_terminal_control_preserves_wire_order(event_type: str):
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
|
||||
await actor.enqueue_event({"type": event_type})
|
||||
|
||||
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
|
||||
assert await actor.next_event() == {"type": event_type}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mailbox_delivers_every_enqueued_event_exactly_once():
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
events = [
|
||||
{"type": "input_audio_buffer.append", "audio": str(index)}
|
||||
if index % 2
|
||||
else {"type": "response.created", "response_id": f"resp-{index}"}
|
||||
for index in range(20)
|
||||
]
|
||||
|
||||
await asyncio.gather(*(actor.enqueue_event(dict(event)) for event in events))
|
||||
received = [await actor.next_event() for _ in events]
|
||||
|
||||
assert sorted(received, key=repr) == sorted(events, key=repr)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writer_is_single_owner_of_websocket_send():
|
||||
websocket = FakeWebSocket()
|
||||
actor = DuplexWebSocketActor(websocket)
|
||||
writer = asyncio.create_task(actor.writer_loop())
|
||||
|
||||
await actor.send_json({"type": "one"})
|
||||
await actor.send_json({"type": "two"})
|
||||
await actor.close_writer()
|
||||
await writer
|
||||
|
||||
assert websocket.sent == [{"type": "one"}, {"type": "two"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_fence_payload_is_dropped_before_websocket_send():
|
||||
websocket = FakeWebSocket()
|
||||
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 2)
|
||||
writer = asyncio.create_task(actor.writer_loop())
|
||||
|
||||
await actor.send_json({"type": "response.audio.delta", "epoch": 1})
|
||||
await actor.send_json({"type": "response.audio.delta", "epoch": 2})
|
||||
await actor.close_writer()
|
||||
await writer
|
||||
|
||||
assert websocket.sent == [{"type": "response.audio.delta", "epoch": 2}]
|
||||
assert actor.stale_output_dropped == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writer_does_not_revoke_accepted_terminal_after_close_starts():
|
||||
websocket = FakeWebSocket()
|
||||
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 1)
|
||||
await actor.send_json({"type": "response.done", "epoch": 1, "response_id": "resp-1"})
|
||||
actor.closing = True
|
||||
await actor.close_writer()
|
||||
|
||||
await actor.writer_loop()
|
||||
|
||||
assert websocket.sent == [{"type": "response.done", "epoch": 1, "response_id": "resp-1"}]
|
||||
assert actor.stale_output_dropped == 0
|
||||
@@ -0,0 +1,240 @@
|
||||
import base64
|
||||
import wave
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.client import (
|
||||
RealtimeDuplexClient,
|
||||
RealtimeEventCollector,
|
||||
build_realtime_url,
|
||||
read_pcm16_wav,
|
||||
write_pcm16_wav,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_realtime_client_builds_explicit_native_duplex_url():
|
||||
url = build_realtime_url(
|
||||
"ws://localhost:8099/v1/realtime?custom=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
session_id="session-a",
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query == {
|
||||
"custom": ["1"],
|
||||
"duplex": ["1"],
|
||||
"model": ["openbmb/MiniCPM-o-4_5"],
|
||||
"minicpmo45_native_duplex": ["1"],
|
||||
"session_id": ["session-a"],
|
||||
}
|
||||
|
||||
|
||||
def test_realtime_client_builds_resume_only_url_when_autostart_disabled():
|
||||
url = build_realtime_url(
|
||||
"ws://localhost:8099/v1/realtime?duplex=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
autostart=False,
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query["autostart"] == ["0"]
|
||||
assert query["minicpmo45_native_duplex"] == ["1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_client_configure_omits_ref_audio_by_default():
|
||||
class Client(RealtimeDuplexClient):
|
||||
def __init__(self):
|
||||
super().__init__("ws://unused")
|
||||
self.sent = []
|
||||
|
||||
async def send(self, event):
|
||||
self.sent.append(event)
|
||||
self.events.add({"type": "session.created"})
|
||||
|
||||
client = Client()
|
||||
|
||||
await client.configure("openbmb/MiniCPM-o-4_5", timeout_s=1)
|
||||
|
||||
session = client.sent[0]["session"]
|
||||
assert "ref_audio" not in session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_client_configure_sends_explicit_ref_audio():
|
||||
class Client(RealtimeDuplexClient):
|
||||
def __init__(self):
|
||||
super().__init__("ws://unused")
|
||||
self.sent = []
|
||||
|
||||
async def send(self, event):
|
||||
self.sent.append(event)
|
||||
self.events.add({"type": "session.created"})
|
||||
|
||||
client = Client()
|
||||
|
||||
await client.configure(
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
ref_audio="data:audio/wav;base64,AAAA",
|
||||
timeout_s=1,
|
||||
)
|
||||
|
||||
session = client.sent[0]["session"]
|
||||
assert session["ref_audio"] == "data:audio/wav;base64,AAAA"
|
||||
|
||||
|
||||
def test_realtime_event_collector_partitions_audio_by_response():
|
||||
collector = RealtimeEventCollector()
|
||||
collector.add({"type": "response.created", "response": {"id": "resp-a"}})
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(b"audio-a").decode("ascii"),
|
||||
"sample_rate_hz": 16_000,
|
||||
}
|
||||
)
|
||||
|
||||
assert collector.response_ids == ["resp-a"]
|
||||
assert collector.audio_bytes("resp-a") == b"audio-a"
|
||||
assert collector.output_sample_rate_hz == 16_000
|
||||
assert collector.first_received_at("response.created") is not None
|
||||
assert collector.last_received_at("response.audio.delta") is not None
|
||||
|
||||
|
||||
def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
|
||||
collector = RealtimeEventCollector()
|
||||
collector.add(
|
||||
{"type": "response.created", "response": {"id": "resp-a"}},
|
||||
received_at_s=10.0,
|
||||
)
|
||||
stage_metrics = {
|
||||
"0": {
|
||||
"num_tokens_out": 4,
|
||||
"vllm_ttft_ms": 120.0,
|
||||
"vllm_tpot_ms": 15.0,
|
||||
"vllm_itl_ms": 14.0,
|
||||
"vllm_itls_ms": [10.0, 14.0, 18.0],
|
||||
}
|
||||
}
|
||||
for received_at_s, cumulative_audio_ms in ((10.2, 80), (10.25, 160), (10.36, 240)):
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(b"audio").decode("ascii"),
|
||||
"sample_rate_hz": 16_000,
|
||||
"metadata": {
|
||||
"audio_duration_ms": cumulative_audio_ms,
|
||||
"vllm_omni": {"stage_metrics": stage_metrics},
|
||||
},
|
||||
},
|
||||
received_at_s=received_at_s,
|
||||
)
|
||||
|
||||
timing = collector.timing_summary(
|
||||
after_s=10.0,
|
||||
input_committed_at_s=9.9,
|
||||
response_id="resp-a",
|
||||
)
|
||||
|
||||
assert timing["stage0_tokens"] == {
|
||||
"source": "engine_stage_metrics",
|
||||
"output_token_count": 4,
|
||||
"ttft_ms": 120.0,
|
||||
"tpot_ms": 15.0,
|
||||
"inter_token_interval_ms": {
|
||||
"count": 3,
|
||||
"mean": 14.0,
|
||||
"p50": 14.0,
|
||||
"p95": 18.0,
|
||||
"max": 18.0,
|
||||
},
|
||||
}
|
||||
assert timing["audio_output"] == {
|
||||
"source": "client_monotonic_receive",
|
||||
"chunk_count": 3,
|
||||
"response_created_to_first_audio_ms": 200.0,
|
||||
"commit_to_first_audio_ms": 300.0,
|
||||
"inter_chunk_interval_ms": {
|
||||
"count": 2,
|
||||
"mean": 80.0,
|
||||
"p50": 50.0,
|
||||
"p95": 110.0,
|
||||
"max": 110.0,
|
||||
},
|
||||
"chunk_duration_ms": {
|
||||
"count": 3,
|
||||
"mean": 80.0,
|
||||
"p50": 80.0,
|
||||
"p95": 80.0,
|
||||
"max": 80.0,
|
||||
},
|
||||
"max_chunk_gap_ms": 110.0,
|
||||
}
|
||||
|
||||
|
||||
def test_response_timing_ignores_unowned_session_level_metrics():
|
||||
collector = RealtimeEventCollector()
|
||||
collector.add(
|
||||
{"type": "response.created", "response": {"id": "resp-a"}},
|
||||
received_at_s=10.0,
|
||||
)
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(b"audio").decode("ascii"),
|
||||
"metadata": {
|
||||
"vllm_omni": {
|
||||
"stage_metrics": {
|
||||
"0": {
|
||||
"num_tokens_out": 20,
|
||||
"vllm_ttft_ms": 157.0,
|
||||
"vllm_tpot_ms": 16.0,
|
||||
"vllm_itls_ms": [15.0, 17.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
received_at_s=10.2,
|
||||
)
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.listen",
|
||||
"metadata": {
|
||||
"vllm_omni": {
|
||||
"stage_metrics": {
|
||||
"0": {
|
||||
"num_tokens_out": 2,
|
||||
"vllm_ttft_ms": 106.0,
|
||||
"vllm_tpot_ms": 0.0,
|
||||
"vllm_itls_ms": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
received_at_s=10.3,
|
||||
)
|
||||
|
||||
timing = collector.timing_summary(after_s=10.0, response_id="resp-a")
|
||||
|
||||
assert timing["stage0_tokens"]["output_token_count"] == 20
|
||||
assert timing["stage0_tokens"]["ttft_ms"] == 157.0
|
||||
|
||||
|
||||
def test_realtime_client_pcm16_wav_round_trip(tmp_path):
|
||||
path = tmp_path / "audio.wav"
|
||||
pcm16 = b"\x01\x00\x02\x00"
|
||||
|
||||
write_pcm16_wav(path, pcm16, sample_rate_hz=16_000)
|
||||
|
||||
with wave.open(str(path), "rb") as wav_file:
|
||||
assert wav_file.getnchannels() == 1
|
||||
assert wav_file.getframerate() == 16_000
|
||||
assert read_pcm16_wav(path) == pcm16
|
||||
@@ -5,7 +5,6 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import (
|
||||
MiniCPMO45OmniTTSForConditionalGeneration,
|
||||
@@ -14,10 +13,19 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import (
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeMiniCPMTTS:
|
||||
def __init__(self, config, audio_tokenizer):
|
||||
self.config = config
|
||||
self.audio_tokenizer = audio_tokenizer
|
||||
self.emb_text = object()
|
||||
self.projector_semantic = object()
|
||||
|
||||
def generate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_hf_model_id_path_caching(mocker, tmp_path):
|
||||
"""Verify HF model-ID path is resolved via download_weights_from_hf_specific
|
||||
once during _lazy_init_tts, cached in self._model_path, and reused in generate_speech.
|
||||
"""
|
||||
"""Verify the resolved HF path is cached and reused for default ref audio."""
|
||||
resolved_path = str(tmp_path / "resolved_hf_model")
|
||||
assets_dir = tmp_path / "resolved_hf_model" / "assets"
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -29,11 +37,9 @@ def test_hf_model_id_path_caching(mocker, tmp_path):
|
||||
return_value=resolved_path,
|
||||
)
|
||||
|
||||
# Mock dynamic class loading
|
||||
fake_tts_class = mocker.MagicMock()
|
||||
mocker.patch(
|
||||
"transformers.dynamic_module_utils.get_class_from_dynamic_module",
|
||||
return_value=fake_tts_class,
|
||||
return_value=_FakeMiniCPMTTS,
|
||||
)
|
||||
|
||||
# Build fake vllm_config with HF model ID
|
||||
@@ -59,40 +65,10 @@ def test_hf_model_id_path_caching(mocker, tmp_path):
|
||||
|
||||
tts_model = MiniCPMO45OmniTTSForConditionalGeneration(vllm_config=vllm_config)
|
||||
|
||||
# 1. Trigger lazy init
|
||||
tts_model._lazy_init_tts()
|
||||
tts_model._lazy_init_tts()
|
||||
|
||||
# Verify download_weights_from_hf_specific was called with HF model ID and cached
|
||||
mock_download.assert_called_once_with(model_id, None, ["*"])
|
||||
assert tts_model._model_path == resolved_path
|
||||
assert tts_model._assets_loaded is True
|
||||
|
||||
# 2. Mock TTS object generate method and audio tokenizer for generate_speech
|
||||
fake_tts_obj = mocker.MagicMock()
|
||||
fake_tts_obj.emb_text.weight.device = torch.device("cpu")
|
||||
fake_tts_obj.emb_text.side_effect = lambda t: torch.zeros(t.shape[0], 768)
|
||||
fake_tts_obj.projector_semantic.side_effect = lambda h, **kw: torch.zeros(h.shape[0], 768)
|
||||
fake_tts_obj.config = tts_cfg
|
||||
fake_tts_obj.audio_bos_token_id = 151687
|
||||
|
||||
fake_output = SimpleNamespace(new_ids=torch.tensor([[[100]]]))
|
||||
fake_tts_obj.generate.return_value = fake_output
|
||||
|
||||
tts_model.tts_obj = fake_tts_obj
|
||||
fake_tokenizer = mocker.MagicMock(return_value=b"dummy_wav_bytes")
|
||||
tts_model.audio_tokenizer = fake_tokenizer
|
||||
|
||||
# Mock sf.read to return fake numpy audio
|
||||
mocker.patch(
|
||||
"soundfile.read",
|
||||
return_value=(torch.zeros(16000).numpy(), 24000),
|
||||
)
|
||||
|
||||
# Call generate_speech
|
||||
tts_token_ids = torch.tensor([1, 2, 3])
|
||||
tts_hidden_states = torch.zeros(3, 768)
|
||||
waveform = tts_model.generate_speech(tts_token_ids, tts_hidden_states)
|
||||
|
||||
# Verify download_weights_from_hf_specific was NOT called a second time
|
||||
assert mock_download.call_count == 1
|
||||
assert waveform is not None
|
||||
assert tts_model._resolve_prompt_wav_path(None, None) == (str(ref_audio_file), None)
|
||||
@@ -7,11 +7,9 @@ Covers ``vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni.llm2t
|
||||
- empty ``source_outputs`` raises
|
||||
- latent fallback to ``hidden_states`` when ``multimodal_output`` is empty
|
||||
- both inputs missing -> raises
|
||||
- additional_information payload carries the keys the talker expects
|
||||
(prompt_embeds, prompt_token_ids, llm_output_token_ids, llm_output_text)
|
||||
- dummy talker prompt ``[BOS, PAD, EOS] = [1, 0, 2]`` (single prefill step)
|
||||
- structured model_intermediate_buffer carries the thinker ids and text
|
||||
- scheduler prompt tokens follow the selected TTS region or output tokens
|
||||
- MiniCPM-o 4.5 TTS region detection on 151703 / 151704 tokens
|
||||
- MiniCPM-o 2.6 fallback detection on 151691 / 151692 when no 4.5 markers
|
||||
- No TTS markers present -> no ``tts_token_ids`` / ``tts_hidden_states`` keys
|
||||
- prompt arg is normalized to a list and ``multi_modal_data`` is gated by
|
||||
``requires_multimodal_data``
|
||||
@@ -24,6 +22,9 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
|
||||
MiniCPMO45OmniForConditionalGeneration,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni import llm2tts
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -32,6 +33,55 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
_HIDDEN_DIM = 4
|
||||
|
||||
|
||||
def test_native_duplex_talker_emits_empty_text_metadata_to_clear_previous_segment() -> None:
|
||||
class _Talker:
|
||||
_ar_last_chunk_flags = [True]
|
||||
_ar_turn_end_flags = [True]
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
del kwargs
|
||||
return None, torch.zeros(0, dtype=torch.float32)
|
||||
|
||||
model = MiniCPMO45OmniForConditionalGeneration.__new__(MiniCPMO45OmniForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.model_stage = "tts"
|
||||
model.config = SimpleNamespace(hidden_size=_HIDDEN_DIM)
|
||||
model.talker = _Talker()
|
||||
|
||||
output = model.forward(
|
||||
input_ids=torch.tensor([1]),
|
||||
positions=torch.tensor([0]),
|
||||
runtime_additional_information=[
|
||||
{
|
||||
"native_duplex": True,
|
||||
"duplex": {"turn_id": 1, "epoch": 0},
|
||||
"meta": {"native_duplex_segment_text": ""},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert output.multimodal_outputs is not None
|
||||
assert output.multimodal_outputs["meta.llm_output_text_utf8"].numel() == 0
|
||||
assert output.multimodal_outputs["meta.audio_text_total_chars"].tolist() == [0]
|
||||
|
||||
|
||||
def test_tts_embed_input_ids_uses_active_talker_embedding(monkeypatch) -> None:
|
||||
model = MiniCPMO45OmniForConditionalGeneration.__new__(MiniCPMO45OmniForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.model_stage = "tts"
|
||||
expected = torch.ones((2, _HIDDEN_DIM))
|
||||
|
||||
monkeypatch.setattr(
|
||||
model,
|
||||
"get_input_embeddings",
|
||||
lambda input_ids: expected,
|
||||
)
|
||||
|
||||
actual = model.embed_input_ids(torch.tensor([11, 12]))
|
||||
|
||||
assert actual is expected
|
||||
|
||||
|
||||
def _make_thinker_output(
|
||||
*,
|
||||
prompt_token_ids: list[int],
|
||||
@@ -40,6 +90,7 @@ def _make_thinker_output(
|
||||
request_id: str = "req-0",
|
||||
latent: torch.Tensor | None = None,
|
||||
hidden_states: torch.Tensor | None = None,
|
||||
multimodal_output: dict[str, object] | None = None,
|
||||
):
|
||||
"""Construct a minimal mock of a thinker engine output entry.
|
||||
|
||||
@@ -48,8 +99,11 @@ def _make_thinker_output(
|
||||
- per-output: ``multimodal_output`` (dict), ``hidden_states`` (opt),
|
||||
``text``, ``token_ids``
|
||||
"""
|
||||
mm_output = dict(multimodal_output or {})
|
||||
if latent is not None:
|
||||
mm_output["latent"] = latent
|
||||
output = SimpleNamespace(
|
||||
multimodal_output={"latent": latent} if latent is not None else {},
|
||||
multimodal_output=mm_output,
|
||||
token_ids=output_token_ids,
|
||||
text=text,
|
||||
)
|
||||
@@ -90,17 +144,16 @@ class TestBasicShape:
|
||||
)
|
||||
assert len(out) == 2
|
||||
|
||||
def test_talker_prompt_token_ids_dummy_bos_pad_eos(self) -> None:
|
||||
def test_talker_scheduler_prompt_uses_output_tokens(self) -> None:
|
||||
hidden = torch.zeros((2, _HIDDEN_DIM))
|
||||
out = llm2tts(
|
||||
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden)],
|
||||
prompt=None,
|
||||
)
|
||||
# The talker AR framework needs *some* tokens to do a single prefill;
|
||||
# this is the agreed [BOS, PAD, EOS] minimal payload.
|
||||
assert out[0]["prompt_token_ids"] == [1, 0, 2]
|
||||
assert out[0]["prompt_token_ids"] == [20]
|
||||
assert "stream_output" not in out[0]["model_intermediate_buffer"]
|
||||
|
||||
def test_additional_information_carries_thinker_outputs(self) -> None:
|
||||
def test_model_intermediate_buffer_carries_thinker_outputs(self) -> None:
|
||||
prompt_ids = [10, 11, 12]
|
||||
out_ids = [20, 21]
|
||||
hidden = torch.randn(len(prompt_ids) + len(out_ids), _HIDDEN_DIM)
|
||||
@@ -116,21 +169,17 @@ class TestBasicShape:
|
||||
],
|
||||
prompt=None,
|
||||
)
|
||||
ai = result[0]["additional_information"]
|
||||
assert ai["prompt_token_ids"] == prompt_ids
|
||||
assert ai["llm_output_token_ids"] == out_ids
|
||||
assert ai["llm_output_text"] == ["hello"]
|
||||
# prompt_embeds = float32 view of the prompt-portion of hidden states.
|
||||
assert ai["prompt_embeds"].dtype == torch.float32
|
||||
assert ai["prompt_embeds"].shape == (len(prompt_ids), _HIDDEN_DIM)
|
||||
assert torch.equal(ai["prompt_embeds"], hidden[: len(prompt_ids)].to(torch.float32))
|
||||
buffer = result[0]["model_intermediate_buffer"]
|
||||
assert buffer["ids"]["prompt"] == prompt_ids
|
||||
assert buffer["ids"]["output"] == out_ids
|
||||
assert buffer["llm_output_text"] == ["hello"]
|
||||
|
||||
def test_latent_in_multimodal_output_takes_precedence(self) -> None:
|
||||
# When both ``multimodal_output["latent"]`` and ``hidden_states`` are
|
||||
# present, the latent payload must win (this is the steady-state path
|
||||
# produced by the thinker stage).
|
||||
prompt_ids = [10, 11]
|
||||
out_ids = [20]
|
||||
out_ids = [151703, 30, 151704]
|
||||
latent = torch.ones((len(prompt_ids) + len(out_ids), _HIDDEN_DIM))
|
||||
hidden = torch.zeros_like(latent)
|
||||
|
||||
@@ -145,19 +194,17 @@ class TestBasicShape:
|
||||
],
|
||||
prompt=None,
|
||||
)
|
||||
ai = result[0]["additional_information"]
|
||||
buffer = result[0]["model_intermediate_buffer"]
|
||||
# latent (ones) won over hidden_states (zeros)
|
||||
assert torch.equal(ai["prompt_embeds"], latent[: len(prompt_ids)].to(torch.float32))
|
||||
assert torch.equal(torch.tensor(buffer["hidden_states"]["tts"]), latent[3:4].to(torch.float32))
|
||||
|
||||
|
||||
class TestTtsRegionDetection:
|
||||
"""The bridge auto-detects which MiniCPM-o generation produced the output
|
||||
by sniffing for 4.5-specific BOS/EOS token ids (151703 / 151704); when
|
||||
those are absent it falls back to the 2.6 ids (151691 / 151692).
|
||||
"""The bridge detects MiniCPM-o 4.5 BOS/EOS ids (151703 / 151704).
|
||||
|
||||
This is a regression guard: a single off-by-one or wrong branch
|
||||
precedence here causes the talker to receive an empty / wrong-region
|
||||
slice and emit silent audio.
|
||||
causes the talker to receive an empty / wrong-region slice and emit
|
||||
silent audio.
|
||||
"""
|
||||
|
||||
def _run(self, prompt_token_ids, output_token_ids):
|
||||
@@ -173,7 +220,7 @@ class TestTtsRegionDetection:
|
||||
],
|
||||
prompt=None,
|
||||
)
|
||||
return result[0]["additional_information"], hidden
|
||||
return result[0]["model_intermediate_buffer"], hidden
|
||||
|
||||
def test_4_5_markers_detected(self) -> None:
|
||||
# prompt: [10, 11]
|
||||
@@ -181,45 +228,26 @@ class TestTtsRegionDetection:
|
||||
# full sequence: [10, 11, 151703, 30, 31, 151704, 40]
|
||||
# 0 1 2 3 4 5 6
|
||||
# 4.5 BOS at idx 2 -> slice starts at 3; EOS at idx 5 -> slice ends at 5.
|
||||
ai, hidden = self._run([10, 11], [151703, 30, 31, 151704, 40])
|
||||
assert "tts_token_ids" in ai and "tts_hidden_states" in ai
|
||||
assert ai["tts_token_ids"].tolist() == [30, 31]
|
||||
assert torch.equal(ai["tts_hidden_states"], hidden[3:5])
|
||||
|
||||
def test_4_5_takes_precedence_when_both_markers_present(self) -> None:
|
||||
# If both 2.6 and 4.5 markers appear (shouldn't really happen on a
|
||||
# real checkpoint, but exercises the loop's break), the 4.5 markers
|
||||
# win because the source loop pins on the first 4.5 id seen.
|
||||
# sequence index: 0 1 2 3 4 5 6 7 8
|
||||
ai, hidden = self._run([10], [151691, 70, 151692, 151703, 30, 151704, 40])
|
||||
assert ai["tts_token_ids"].tolist() == [30]
|
||||
assert torch.equal(ai["tts_hidden_states"], hidden[5:6])
|
||||
|
||||
def test_2_6_fallback(self) -> None:
|
||||
# No 4.5 markers anywhere -> use 2.6 BOS/EOS pair.
|
||||
# full sequence: [10, 151691, 30, 31, 151692, 40]
|
||||
# 0 1 2 3 4 5
|
||||
# BOS at idx 1 -> slice starts at 2; EOS at idx 4 -> slice ends at 4.
|
||||
ai, hidden = self._run([10], [151691, 30, 31, 151692, 40])
|
||||
assert ai["tts_token_ids"].tolist() == [30, 31]
|
||||
assert torch.equal(ai["tts_hidden_states"], hidden[2:4])
|
||||
buffer, hidden = self._run([10, 11], [151703, 30, 31, 151704, 40])
|
||||
assert buffer["ids"]["tts"] == [30, 31]
|
||||
assert torch.equal(torch.tensor(buffer["hidden_states"]["tts"]), hidden[3:5])
|
||||
|
||||
def test_bos_without_eos_runs_to_end(self) -> None:
|
||||
# When BOS is found but EOS is missing (typical for an in-flight or
|
||||
# truncated decode), the slice should extend to the end of the
|
||||
# hidden-state matrix instead of being dropped.
|
||||
# sequence: [10, 11, 151703, 30, 31]
|
||||
ai, hidden = self._run([10, 11], [151703, 30, 31])
|
||||
assert ai["tts_token_ids"].tolist() == [30, 31]
|
||||
assert torch.equal(ai["tts_hidden_states"], hidden[3:5])
|
||||
buffer, hidden = self._run([10, 11], [151703, 30, 31])
|
||||
assert buffer["ids"]["tts"] == [30, 31]
|
||||
assert torch.equal(torch.tensor(buffer["hidden_states"]["tts"]), hidden[3:5])
|
||||
|
||||
def test_no_tts_markers_omits_slice_keys(self) -> None:
|
||||
# If neither marker pair is present, the bridge should NOT populate
|
||||
# ``tts_token_ids`` / ``tts_hidden_states`` — the talker should fall
|
||||
# through to the dummy path.
|
||||
ai, _ = self._run([10, 11], [20, 21, 22])
|
||||
assert "tts_token_ids" not in ai
|
||||
assert "tts_hidden_states" not in ai
|
||||
buffer, _ = self._run([10, 11], [20, 21, 22])
|
||||
assert "tts" not in buffer["ids"]
|
||||
assert "tts" not in buffer.get("hidden_states", {})
|
||||
|
||||
|
||||
class TestPromptAndMultiModal:
|
||||
@@ -251,14 +279,11 @@ class TestPromptAndMultiModal:
|
||||
)
|
||||
assert out[0]["multi_modal_data"] == mm
|
||||
|
||||
def test_streaming_context_is_accepted_and_ignored(self) -> None:
|
||||
# The framework passes ``streaming_context`` for streaming-capable
|
||||
# pipelines; MiniCPM-o 4.5 is turn-taking so the bridge must accept
|
||||
# but discard it without crashing.
|
||||
def test_internal_streaming_context_is_accepted(self) -> None:
|
||||
hidden = torch.zeros((2, _HIDDEN_DIM))
|
||||
out = llm2tts(
|
||||
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden)],
|
||||
prompt=None,
|
||||
streaming_context=object(),
|
||||
_streaming_context=object(),
|
||||
)
|
||||
assert len(out) == 1
|
||||
@@ -44,6 +44,20 @@ class TestRegistryDeclaration:
|
||||
assert pipeline.model_type == _PIPELINE_KEY
|
||||
assert pipeline.model_arch == "MiniCPMO45OmniForConditionalGeneration"
|
||||
|
||||
def test_native_duplex_control_is_explicit_without_a_fixed_session_cap(self) -> None:
|
||||
pipeline = OMNI_PIPELINES[_PIPELINE_KEY]
|
||||
assert pipeline.duplex_control_enabled is True
|
||||
assert pipeline.duplex_serving_adapter == (
|
||||
"vllm_omni.experimental.fullduplex.minicpmo45.serving_adapter.MiniCPMO45ServingRuntimeAdapter"
|
||||
)
|
||||
assert not hasattr(pipeline, "max_native_duplex_sessions")
|
||||
|
||||
def test_ordinary_pipeline_defaults_to_no_duplex_control(self) -> None:
|
||||
pipeline = PipelineConfig(model_type="ordinary")
|
||||
assert pipeline.duplex_control_enabled is False
|
||||
assert pipeline.duplex_serving_adapter is None
|
||||
assert not hasattr(pipeline, "max_native_duplex_sessions")
|
||||
|
||||
|
||||
class TestPipelineTopology:
|
||||
@pytest.fixture(scope="class")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -131,6 +131,18 @@ class TestStageConfig:
|
||||
assert "max_num_seqs" not in omega_config.engine_args
|
||||
# Legacy field name for backward compatibility
|
||||
assert omega_config.engine_input_source == []
|
||||
assert omega_config.session_mode == "turn"
|
||||
|
||||
def test_to_omegaconf_duplex_session_mode(self):
|
||||
"""Test that session mode is preserved as stage metadata."""
|
||||
config = StageConfig(
|
||||
stage_id=0,
|
||||
model_stage="thinker",
|
||||
session_mode="duplex",
|
||||
)
|
||||
omega_config = config.to_omegaconf()
|
||||
|
||||
assert omega_config.session_mode == "duplex"
|
||||
|
||||
def test_to_omegaconf_with_runtime_overrides(self):
|
||||
"""Test that runtime overrides are applied to OmegaConf output."""
|
||||
@@ -1107,6 +1119,57 @@ class TestResolveScheduler:
|
||||
|
||||
|
||||
class TestDeployConfigLoading:
|
||||
def test_load_minicpmo_duplex_deploy_config(self):
|
||||
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "minicpmo_4_5_duplex.yaml"
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
pipeline = resolve_pipeline_config("minicpmo_4_5")
|
||||
assert isinstance(pipeline, PipelineConfig)
|
||||
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
|
||||
assert deploy.session_mode == "duplex"
|
||||
assert deploy.async_chunk is False
|
||||
assert deploy.active_stream_window == 1
|
||||
assert [stage.session_mode for stage in stages] == ["duplex", "duplex"]
|
||||
assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex"]
|
||||
assert [stage.yaml_engine_args["async_scheduling"] for stage in stages] == [False, False]
|
||||
assert all("Async" not in (stage.scheduler_cls or "") for stage in stages)
|
||||
assert deploy.stages[1].enforce_eager is False
|
||||
assert deploy.stages[1].compilation_config == {"cudagraph_mode": "PIECEWISE"}
|
||||
assert stages[1].yaml_extras["default_sampling_params"]["max_tokens"] == 4096
|
||||
assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [151645]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "stage0_devices", "stage1_devices", "stage1_replicas"),
|
||||
[
|
||||
("minicpmo_4_5_3gpu_stage1_replicas.yaml", "0", "1,2", 2),
|
||||
("minicpmo_4_5_4gpu_stage1_replicas.yaml", "0", "1,2,3", 3),
|
||||
("minicpmo_4_5_8x4090_stage1_replicas.yaml", "0,1,2,3", "4,5,6,7", 4),
|
||||
],
|
||||
)
|
||||
def test_load_minicpmo_replica_deploy_configs(
|
||||
self,
|
||||
filename: str,
|
||||
stage0_devices: str,
|
||||
stage1_devices: str,
|
||||
stage1_replicas: int,
|
||||
):
|
||||
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / filename
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
pipeline = resolve_pipeline_config("minicpmo_4_5")
|
||||
assert isinstance(pipeline, PipelineConfig)
|
||||
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
|
||||
assert deploy.session_mode == "duplex"
|
||||
assert [stage.yaml_runtime["devices"] for stage in stages] == [stage0_devices, stage1_devices]
|
||||
assert stages[0].yaml_runtime["num_replicas"] == 1
|
||||
assert stages[1].yaml_runtime["num_replicas"] == stage1_replicas
|
||||
assert all(stage.yaml_engine_args["async_scheduling"] is False for stage in stages)
|
||||
assert all("Async" not in (stage.scheduler_cls or "") for stage in stages)
|
||||
|
||||
def test_custom_voice_dir_is_pipeline_wide_engine_arg(self, tmp_path):
|
||||
deploy_path = tmp_path / "qwen3_tts_custom_voice.yaml"
|
||||
custom_voice_dir = tmp_path / "voices"
|
||||
|
||||
@@ -21,6 +21,8 @@ from vllm_omni.data_entry_keys import (
|
||||
)
|
||||
from vllm_omni.engine import AdditionalInformationPayload
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class TestOmniPayloadStruct:
|
||||
"""Runtime-validated mirror of OmniPayload (msgspec.Struct)."""
|
||||
@@ -43,6 +45,16 @@ class TestOmniPayloadStruct:
|
||||
with pytest.raises(msgspec.ValidationError, match="unknown field"):
|
||||
to_struct({"meta": {"finisheed": True}})
|
||||
|
||||
def test_to_struct_rejects_minicpmo_duplex_runtime_handoff_fields(self):
|
||||
with pytest.raises(msgspec.ValidationError, match="unknown field"):
|
||||
to_struct({"hidden_states": {"tts": torch.zeros(1)}})
|
||||
with pytest.raises(msgspec.ValidationError, match="unknown field"):
|
||||
to_struct({"ids": {"tts": [1]}})
|
||||
with pytest.raises(msgspec.ValidationError, match="unknown field"):
|
||||
to_struct({"meta": {"ref_audio_sr": 16000}})
|
||||
with pytest.raises(msgspec.ValidationError, match="unknown field"):
|
||||
to_struct({"meta": {"listen_token_id": 151685}})
|
||||
|
||||
def test_to_struct_rejects_wrong_type(self):
|
||||
with pytest.raises(msgspec.ValidationError, match="Expected"):
|
||||
to_struct({"meta": {"left_context_size": "not_an_int"}})
|
||||
@@ -66,10 +78,10 @@ class TestOmniPayloadStruct:
|
||||
|
||||
def test_struct_with_all_categories(self):
|
||||
d = {
|
||||
"hidden_states": {"output": torch.zeros(1)},
|
||||
"hidden_states": {"output": torch.zeros(1), "last": torch.ones(2, 4)},
|
||||
"embed": {"prefill": torch.zeros(1), "tts_bos": torch.zeros(1)},
|
||||
"ids": {"all": [1, 2], "prompt": [1]},
|
||||
"codes": {"audio": torch.zeros(1)},
|
||||
"ids": {"all": [1, 2], "prompt": [1], "speech_token": [3, 4]},
|
||||
"codes": {"audio": torch.zeros(1), "ref": torch.ones(4)},
|
||||
"meta": {"left_context_size": 3, "num_processed_tokens": 7},
|
||||
}
|
||||
s = to_struct(d)
|
||||
@@ -79,7 +91,10 @@ class TestOmniPayloadStruct:
|
||||
assert isinstance(s.codes, CodesStruct)
|
||||
assert isinstance(s.meta, MetaStruct)
|
||||
assert s.ids.all == [1, 2]
|
||||
assert s.ids.speech_token == [3, 4]
|
||||
assert torch.equal(s.hidden_states.last, torch.ones(2, 4))
|
||||
assert s.meta.num_processed_tokens == 7
|
||||
assert torch.equal(s.codes.ref, torch.ones(4))
|
||||
|
||||
|
||||
class TestValidatePayload:
|
||||
@@ -332,7 +347,7 @@ class TestSerializeDeserializePayload:
|
||||
"hidden_states": {"output": torch.tensor([1.0, 2.0])},
|
||||
"ids": {"all": [1, 2, 3]},
|
||||
"meta": {"finished": torch.tensor(False, dtype=torch.bool), "ar_width": 4},
|
||||
"codes": {"audio": torch.tensor([3.0])},
|
||||
"codes": {"audio": torch.tensor([3.0]), "ref": torch.tensor([0.1, 0.2])},
|
||||
}
|
||||
wire = serialize_payload(original)
|
||||
restored = deserialize_payload(wire)
|
||||
@@ -341,6 +356,7 @@ class TestSerializeDeserializePayload:
|
||||
assert restored["meta"]["finished"].item() is False
|
||||
assert restored["meta"]["ar_width"] == 4
|
||||
assert torch.equal(restored["codes"]["audio"], original["codes"]["audio"])
|
||||
assert torch.equal(restored["codes"]["ref"], original["codes"]["ref"])
|
||||
|
||||
def test_hidden_states_layers_round_trip(self):
|
||||
original = {
|
||||
|
||||
@@ -20,7 +20,7 @@ from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.request import Request
|
||||
|
||||
from vllm_omni.engine import PromptEmbedsPayload
|
||||
from vllm_omni.request import OmniRequest
|
||||
from vllm_omni.request import OmniRequest, OmniStreamingUpdate
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -32,7 +32,12 @@ def test_omni_params_are_keyword_only():
|
||||
came first positionally and broke positional ``Request(...)`` construction.
|
||||
"""
|
||||
params = inspect.signature(OmniRequest.__init__).parameters
|
||||
for name in ("prompt_embeds", "external_req_id", "additional_information"):
|
||||
for name in (
|
||||
"prompt_embeds",
|
||||
"external_req_id",
|
||||
"additional_information",
|
||||
"model_intermediate_buffer",
|
||||
):
|
||||
assert params[name].kind is inspect.Parameter.KEYWORD_ONLY, name
|
||||
|
||||
|
||||
@@ -87,3 +92,21 @@ def test_keyword_omni_params_round_trip():
|
||||
# The serialized payload is retained and decoded into a tensor on the base.
|
||||
assert req.prompt_embeds_payload is payload
|
||||
assert torch.equal(req.prompt_embeds, torch.from_numpy(arr))
|
||||
|
||||
|
||||
def test_model_intermediate_buffer_round_trips_to_streaming_update():
|
||||
buffer = {"duplex": {"turn_id": 3}}
|
||||
request = OmniRequest(
|
||||
request_id="req-model-buffer",
|
||||
prompt_token_ids=[1, 2],
|
||||
sampling_params=SamplingParams(max_tokens=16),
|
||||
pooling_params=None,
|
||||
resumable=True,
|
||||
model_intermediate_buffer=buffer,
|
||||
)
|
||||
|
||||
update = OmniStreamingUpdate.from_request(request)
|
||||
|
||||
assert update is not None
|
||||
assert request.model_intermediate_buffer is buffer
|
||||
assert update.model_intermediate_buffer is buffer
|
||||
@@ -1,7 +1,10 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.utils.mm_outputs import partition_flat_payload, partition_payload_list
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_partition_thinker_latent_payload():
|
||||
payload = {
|
||||
@@ -42,3 +45,27 @@ def test_partition_payload_list_preserves_request_alignment():
|
||||
inter_list, client_list = partition_payload_list(payloads)
|
||||
assert inter_list == [payloads[0], None]
|
||||
assert client_list == [None, payloads[1]]
|
||||
|
||||
|
||||
def test_partition_duplex_audio_transcript_metadata_to_client_mm():
|
||||
payload = {
|
||||
"model_outputs": torch.zeros(1, 2400),
|
||||
"meta.duplex_epoch": torch.tensor([3], dtype=torch.int32),
|
||||
"meta.duplex_turn_id": torch.tensor([2], dtype=torch.int32),
|
||||
"meta.llm_output_text_utf8": torch.tensor([104, 105], dtype=torch.uint8),
|
||||
"meta.audio_text_total_chars": torch.tensor([2], dtype=torch.int32),
|
||||
"meta.tts_is_last_chunk": torch.tensor([1], dtype=torch.int32),
|
||||
"meta.turn_end": torch.tensor([1], dtype=torch.int32),
|
||||
"meta.native_duplex_segment_text": "hi",
|
||||
}
|
||||
|
||||
inter, client = partition_flat_payload(payload)
|
||||
|
||||
assert "meta.llm_output_text_utf8" in client
|
||||
assert "meta.audio_text_total_chars" in client
|
||||
assert "meta.duplex_epoch" in client
|
||||
assert "meta.duplex_turn_id" in client
|
||||
assert "meta.tts_is_last_chunk" in client
|
||||
assert "meta.turn_end" in client
|
||||
assert "meta.native_duplex_segment_text" not in client
|
||||
assert "meta.native_duplex_segment_text" in inter
|
||||
File diff suppressed because it is too large.
Load diff
@@ -196,8 +196,7 @@ def _make_runner_for_mimo(req_id="r_mimo"):
|
||||
|
||||
|
||||
def test_talker_mtp_forward_cpu_updates_inputs_and_info(monkeypatch):
|
||||
# `_talker_mtp_forward` calls `current_omni_platform.set_forward_context`,
|
||||
# which would otherwise dispatch to the real device implementation.
|
||||
# `_talker_mtp_forward` dispatches through the active platform.
|
||||
import vllm_omni.worker.gpu_model_runner as mod # Must be the same module that defines OmniGPUModelRunner
|
||||
|
||||
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
|
||||
@@ -459,28 +458,33 @@ def test_update_intermediate_buffer_skips_unknown_req_id():
|
||||
assert "unknown_req" not in runner.model_intermediate_buffer
|
||||
|
||||
|
||||
def test_maybe_run_batch_preprocess_calls_model_hook():
|
||||
runner = object.__new__(OmniGPUModelRunner)
|
||||
runner.model_intermediate_buffer = {"r1": {"text": ["hello"]}}
|
||||
calls = []
|
||||
def test_streaming_input_update_merges_model_intermediate_buffer():
|
||||
runner = _make_runner(req_ids=("r1",), hidden_size=4)
|
||||
runner.model_intermediate_buffer["r1"] = {
|
||||
"duplex": {
|
||||
"session_id": "sid",
|
||||
"seq": 1,
|
||||
}
|
||||
}
|
||||
runner.requests["r1"].additional_information_cpu = runner.model_intermediate_buffer["r1"]
|
||||
new_req_data = SimpleNamespace(
|
||||
model_intermediate_buffer={
|
||||
"duplex": {
|
||||
"session_id": "sid",
|
||||
"seq": 2,
|
||||
"payload": {"type": "audio"},
|
||||
}
|
||||
},
|
||||
additional_information=None,
|
||||
)
|
||||
|
||||
class DummyModel:
|
||||
def preprocess_batch(self, *, req_ids, model_intermediate_buffer, device):
|
||||
calls.append((req_ids, model_intermediate_buffer, device))
|
||||
OmniGPUModelRunner._update_streaming_input_additional_info(runner, new_req_data, "r1")
|
||||
|
||||
runner.model = DummyModel()
|
||||
|
||||
OmniGPUModelRunner._maybe_run_batch_preprocess(runner, ["r1"], torch.device("cpu"))
|
||||
|
||||
assert calls == [(["r1"], runner.model_intermediate_buffer, torch.device("cpu"))]
|
||||
|
||||
|
||||
def test_maybe_run_batch_preprocess_skips_missing_hook():
|
||||
runner = object.__new__(OmniGPUModelRunner)
|
||||
runner.model_intermediate_buffer = {}
|
||||
runner.model = object()
|
||||
|
||||
OmniGPUModelRunner._maybe_run_batch_preprocess(runner, ["r1"], torch.device("cpu"))
|
||||
info = runner.model_intermediate_buffer["r1"]
|
||||
assert info["duplex"]["session_id"] == "sid"
|
||||
assert info["duplex"]["seq"] == 2
|
||||
assert info["duplex"]["payload"] == {"type": "audio"}
|
||||
assert runner.requests["r1"].additional_information_cpu is info
|
||||
|
||||
|
||||
def _make_full_payload_accumulation_runner(
|
||||
@@ -501,8 +505,6 @@ def _make_full_payload_accumulation_runner(
|
||||
runner._custom_process_func = object()
|
||||
runner._pending_full_payload_send = {}
|
||||
runner._stage_id = 1
|
||||
# Non-None sentinel: the gate short-circuits to False when no connector
|
||||
# is configured at all (terminal stages in pipelines with no connector).
|
||||
runner._omni_connector = object()
|
||||
return runner
|
||||
|
||||
@@ -519,7 +521,7 @@ def test_accumulate_full_payload_output_preserves_aligned_all_zero_qwen3_omni_co
|
||||
|
||||
|
||||
def test_accumulate_full_payload_output_keeps_misaligned_all_zero_qwen3_omni_codec_rows():
|
||||
# After removing the sender-side zero filter, the full-payload accumulator keeps every
|
||||
# After removing the sender-side zero filter, the accumulator keeps every
|
||||
# codec row including misaligned all-zero rows. The downstream consumer
|
||||
# (_extract_qwen3_full_payload_codec_rows) is the authoritative crop and
|
||||
# filters by output_token_ids.
|
||||
@@ -566,45 +568,25 @@ def test_accumulate_full_payload_output_keeps_all_zero_qwen3_omni_prefill_placeh
|
||||
|
||||
|
||||
def test_full_payload_output_accumulation_hook_matrix():
|
||||
"""Producer-side gate: fires iff an explicit next-stage payload hook is loaded.
|
||||
|
||||
A derived `*_full_payload` helper from `custom_process_input_func` is not
|
||||
enough: terminal/input-only consumer stages must not enqueue orphan
|
||||
downstream payloads.
|
||||
"""
|
||||
# Thinker / talker producer stages: explicit next-stage payload hook -> gate fires.
|
||||
assert _make_full_payload_accumulation_runner(model_stage="thinker")._should_accumulate_full_payload_output()
|
||||
assert _make_full_payload_accumulation_runner(model_stage="talker")._should_accumulate_full_payload_output()
|
||||
|
||||
# Terminal stage: even if _load_custom_func derived a builder from
|
||||
# custom_process_input_func, final output stages are not producers.
|
||||
runner = _make_full_payload_accumulation_runner(model_stage="code2wav", final_output=True)
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
|
||||
# Input-only consumer stage without an explicit producer hook must not
|
||||
# accumulate/send just because a same-module *_full_payload helper exists.
|
||||
runner = _make_full_payload_accumulation_runner(
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_stage="code2wav", final_output=True
|
||||
)._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_stage="token2audio",
|
||||
custom_process_next_stage_input_func=None,
|
||||
)
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
|
||||
# async_chunk mode -> gate off.
|
||||
)._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_stage="talker", async_chunk=True
|
||||
)._should_accumulate_full_payload_output()
|
||||
|
||||
# Non-qwen3 arches: gate is arch-agnostic, but if the fixture's arch
|
||||
# does not configure a connector payload builder, its runtime
|
||||
# `_custom_process_func` is None. Emulate that.
|
||||
runner = _make_full_payload_accumulation_runner(model_arch="Qwen3TTSForConditionalGeneration")
|
||||
runner._custom_process_func = None
|
||||
runner._should_accumulate_full_payload_output_cached = None
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
runner = _make_full_payload_accumulation_runner(model_arch="Qwen2_5OmniForConditionalGeneration")
|
||||
runner._custom_process_func = None
|
||||
runner._should_accumulate_full_payload_output_cached = None
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
for model_arch in (
|
||||
"Qwen3TTSForConditionalGeneration",
|
||||
"Qwen2_5OmniForConditionalGeneration",
|
||||
):
|
||||
runner = _make_full_payload_accumulation_runner(model_arch=model_arch)
|
||||
runner._custom_process_func = None
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
|
||||
|
||||
def test_sync_local_stage_payloads_retains_payload_until_request_is_active():
|
||||
|
||||
@@ -270,6 +270,15 @@ class PipelineConfig:
|
||||
# multi-component repos (e.g. GLM-Image). ``None`` = not a diffusers model.
|
||||
diffusers_class_name: str | None = None
|
||||
endpoint_restrictions: tuple[EndpointRestriction, ...] = ()
|
||||
# Optional model-owned duplex planner loaded by the stable engine runtime.
|
||||
duplex_runtime_extension: str | None = None
|
||||
# Optional model-owned Serving adapter loaded only when the Realtime duplex
|
||||
# endpoint is enabled. Generic OpenAI modules must not select a model.
|
||||
duplex_serving_adapter: str | None = None
|
||||
# Explicitly enable the stable duplex control mechanism. This is separate
|
||||
# from the optional model extension because turn-commit-only deployments
|
||||
# do not require a model planner.
|
||||
duplex_control_enabled: bool = False
|
||||
# Bundled deploy defaults for this concrete pipeline topology. The file is
|
||||
# loaded from vllm_omni/deploy; None uses DeployConfig defaults.
|
||||
default_deploy_config_name: str | None = None
|
||||
@@ -406,6 +415,38 @@ class StageDeployConfig:
|
||||
engine_extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexSessionRuntimeConfig:
|
||||
"""Server-owned lifecycle and per-session buffering limits."""
|
||||
|
||||
idle_ttl_s: float | None = 300.0
|
||||
disconnect_grace_s: float = 30.0
|
||||
reaper_interval_s: float = 5.0
|
||||
resume_replay_ttl_s: float = 60.0
|
||||
resume_replay_max_bytes_per_session: int = 8 * 1024 * 1024
|
||||
max_pending_input_bytes_per_session: int = 16 * 1024 * 1024
|
||||
max_pending_turns_per_session: int = 4
|
||||
max_sessions: int = 1
|
||||
completed_append_cache_size: int = 256
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
positive = {
|
||||
"disconnect_grace_s": self.disconnect_grace_s,
|
||||
"reaper_interval_s": self.reaper_interval_s,
|
||||
"resume_replay_ttl_s": self.resume_replay_ttl_s,
|
||||
"resume_replay_max_bytes_per_session": self.resume_replay_max_bytes_per_session,
|
||||
"max_pending_input_bytes_per_session": self.max_pending_input_bytes_per_session,
|
||||
"max_pending_turns_per_session": self.max_pending_turns_per_session,
|
||||
"max_sessions": self.max_sessions,
|
||||
"completed_append_cache_size": self.completed_append_cache_size,
|
||||
}
|
||||
if self.idle_ttl_s is not None and self.idle_ttl_s <= 0:
|
||||
raise ValueError("duplex_session.idle_ttl_s must be positive or null")
|
||||
for name, value in positive.items():
|
||||
if value <= 0:
|
||||
raise ValueError(f"duplex_session.{name} must be positive")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeployConfig:
|
||||
"""Loaded from deploy/<model>.yaml — the only config file users edit.
|
||||
@@ -419,8 +460,10 @@ class DeployConfig:
|
||||
"""
|
||||
|
||||
async_chunk: bool = True
|
||||
session_mode: str = "turn"
|
||||
# Stage-1 active stream slots; 0 preserves legacy all-stream cycling.
|
||||
active_stream_window: int = 0
|
||||
duplex_session: DuplexSessionRuntimeConfig = field(default_factory=DuplexSessionRuntimeConfig)
|
||||
connectors: dict[str, Any] | None = None
|
||||
edges: list[dict[str, Any]] | None = None
|
||||
stages: list[StageDeployConfig] = field(default_factory=list)
|
||||
@@ -610,7 +653,9 @@ def load_deploy_config(path: str | Path) -> DeployConfig:
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"async_chunk": raw_dict.get("async_chunk", True),
|
||||
"session_mode": raw_dict.get("session_mode", "turn"),
|
||||
"active_stream_window": int(raw_dict.get("active_stream_window", 0) or 0),
|
||||
"duplex_session": DuplexSessionRuntimeConfig(**(raw_dict.get("duplex_session") or {})),
|
||||
"connectors": raw_dict.get("connectors", None),
|
||||
"edges": raw_dict.get("edges", None),
|
||||
"stages": stages,
|
||||
@@ -896,6 +941,7 @@ def merge_pipeline_deploy(
|
||||
StageConfig(
|
||||
stage_id=ps.stage_id,
|
||||
model_stage=ps.model_stage,
|
||||
session_mode=deploy.session_mode,
|
||||
stage_type=stage_type,
|
||||
input_sources=list(ps.input_sources),
|
||||
custom_process_input_func=input_proc,
|
||||
@@ -922,6 +968,7 @@ class StageConfig:
|
||||
|
||||
stage_id: int
|
||||
model_stage: str
|
||||
session_mode: str = "turn"
|
||||
stage_type: StageType = StageType.LLM
|
||||
input_sources: list[int] = field(default_factory=list)
|
||||
custom_process_input_func: str | None = None
|
||||
@@ -984,6 +1031,7 @@ class StageConfig:
|
||||
config_dict: dict[str, Any] = {
|
||||
"stage_id": self.stage_id,
|
||||
"stage_type": StageType(self.stage_type).value,
|
||||
"session_mode": self.session_mode,
|
||||
"engine_args": create_config(engine_args),
|
||||
"runtime": create_config(runtime),
|
||||
"engine_input_source": self.input_sources, # Legacy field name
|
||||
|
||||
@@ -2,9 +2,28 @@
|
||||
Scheduling components for vLLM-Omni.
|
||||
"""
|
||||
|
||||
from .omni_ar_scheduler import OmniARAsyncScheduler, OmniARScheduler
|
||||
from .omni_generation_scheduler import OmniGenerationScheduler
|
||||
from .output import OmniNewRequestData
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"OmniARAsyncScheduler": (".omni_ar_scheduler", "OmniARAsyncScheduler"),
|
||||
"OmniARScheduler": (".omni_ar_scheduler", "OmniARScheduler"),
|
||||
"OmniGenerationScheduler": (".omni_generation_scheduler", "OmniGenerationScheduler"),
|
||||
"OmniNewRequestData": (".output", "OmniNewRequestData"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
target = _LAZY_EXPORTS.get(name)
|
||||
if target is None:
|
||||
raise AttributeError(name)
|
||||
module_name, attribute_name = target
|
||||
value = getattr(import_module(module_name, __name__), attribute_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OmniARAsyncScheduler",
|
||||
|
||||
@@ -259,6 +259,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
prompt_embeds=(getattr(request, "prompt_embeds", None) if request else None),
|
||||
prompt_is_token_ids=nr.prompt_is_token_ids,
|
||||
additional_information=(getattr(request, "additional_information", None) if request else None),
|
||||
model_intermediate_buffer=(
|
||||
getattr(request, "model_intermediate_buffer", None) if request else None
|
||||
),
|
||||
)
|
||||
new_list.append(omni_nr)
|
||||
|
||||
@@ -718,6 +721,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
session.record_event(EngineCoreEventType.QUEUED)
|
||||
return
|
||||
super()._update_request_as_session(session, update)
|
||||
if hasattr(update, "model_intermediate_buffer"):
|
||||
session.model_intermediate_buffer = update.model_intermediate_buffer
|
||||
|
||||
def _free_request(self, request: Request, delay_free_blocks: bool = False) -> dict[str, Any] | None:
|
||||
# TODO(wzliu)! for offline mode, we should not end process until all data is transferred
|
||||
|
||||
@@ -319,6 +319,9 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
prompt_embeds=(getattr(request, "prompt_embeds", None) if request else None),
|
||||
prompt_is_token_ids=nr.prompt_is_token_ids,
|
||||
additional_information=(getattr(request, "additional_information", None) if request else None),
|
||||
model_intermediate_buffer=(
|
||||
getattr(request, "model_intermediate_buffer", None) if request else None
|
||||
),
|
||||
)
|
||||
new_list.append(omni_nr)
|
||||
|
||||
|
||||
@@ -40,16 +40,16 @@ except ValueError:
|
||||
class OmniSchedulerMixin:
|
||||
"""Shared scheduler helpers for omni-specific request handling."""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared scheduler/output helpers (lift the AR / generation duplicates)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _free_input_coordinator_request(self, request_id: str) -> None:
|
||||
"""Prune full-payload coordinator state for a completed request."""
|
||||
input_coordinator = getattr(self, "input_coordinator", None)
|
||||
if input_coordinator is not None:
|
||||
input_coordinator.free_finished_request(request_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared scheduler/output helpers (lift the AR / generation duplicates)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _consume_pending_connector_output(self, model_mode: str) -> None:
|
||||
"""Drain ``self._latest_omni_connector_output`` into the coordinator.
|
||||
|
||||
|
||||
@@ -20,10 +20,13 @@ class OmniNewRequestData(NewRequestData):
|
||||
external_req_id: Optional external request ID for tracking
|
||||
additional_information: Optional serialized additional information
|
||||
dictionary containing tensors or lists
|
||||
model_intermediate_buffer: Optional runner-owned payload for
|
||||
GPUModelRunner.model_intermediate_buffer
|
||||
"""
|
||||
|
||||
external_req_id: str | None = None
|
||||
additional_information: AdditionalInformationPayload | None = None
|
||||
model_intermediate_buffer: dict[str, object] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
@@ -56,6 +59,7 @@ class OmniNewRequestData(NewRequestData):
|
||||
prompt_is_token_ids=getattr(request, "prompt_is_token_ids", None),
|
||||
prefill_token_ids=prefill_token_ids,
|
||||
additional_information=getattr(request, "additional_information", None),
|
||||
model_intermediate_buffer=getattr(request, "model_intermediate_buffer", None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Experimental validation profile: one thinker and two Stage1 replicas.
|
||||
# This is not a recommended production entrypoint.
|
||||
#
|
||||
# Stage 0: GPU 0
|
||||
# Stage 1: replicas on GPU 1 and 2
|
||||
base_config: minicpmo_4_5.yaml
|
||||
pipeline: minicpmo_4_5
|
||||
session_mode: duplex
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
async_scheduling: false
|
||||
|
||||
- stage_id: 1
|
||||
devices: "1,2"
|
||||
num_replicas: 2
|
||||
async_scheduling: false
|
||||
@@ -0,0 +1,17 @@
|
||||
# Experimental validation profile: one thinker and three Stage1 replicas.
|
||||
# This is not a recommended production entrypoint.
|
||||
#
|
||||
# Stage 0: GPU 0
|
||||
# Stage 1: replicas on GPU 1, 2, and 3
|
||||
base_config: minicpmo_4_5.yaml
|
||||
pipeline: minicpmo_4_5
|
||||
session_mode: duplex
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
async_scheduling: false
|
||||
|
||||
- stage_id: 1
|
||||
devices: "1,2,3"
|
||||
num_replicas: 3
|
||||
async_scheduling: false
|
||||
@@ -0,0 +1,20 @@
|
||||
# Experimental validation profile: 4-way thinker TP and four Stage1 replicas.
|
||||
# This is not a recommended production entrypoint.
|
||||
#
|
||||
# Stage 0: GPU 0, 1, 2, and 3
|
||||
# Stage 1: replicas on GPU 4, 5, 6, and 7
|
||||
base_config: minicpmo_4_5_8x4090.yaml
|
||||
pipeline: minicpmo_4_5
|
||||
session_mode: duplex
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
async_scheduling: false
|
||||
gpu_memory_utilization: 0.40
|
||||
max_num_batched_tokens: 16384
|
||||
|
||||
- stage_id: 1
|
||||
devices: "4,5,6,7"
|
||||
num_replicas: 4
|
||||
async_scheduling: false
|
||||
gpu_memory_utilization: 0.8
|
||||
@@ -0,0 +1,36 @@
|
||||
# MiniCPM-o 4.5 native duplex deployment.
|
||||
#
|
||||
# The registered minicpmo_4_5 pipeline owns the fixed Stage0 -> Stage1
|
||||
# topology and llm2tts bridge. This overlay only changes session lifetime and
|
||||
# the Stage1 token budget needed to drain streaming audio segments.
|
||||
base_config: minicpmo_4_5.yaml
|
||||
pipeline: minicpmo_4_5
|
||||
session_mode: duplex
|
||||
active_stream_window: 1
|
||||
duplex_session:
|
||||
idle_ttl_s: 300
|
||||
disconnect_grace_s: 30
|
||||
reaper_interval_s: 5
|
||||
resume_replay_ttl_s: 60
|
||||
resume_replay_max_bytes_per_session: 8388608
|
||||
max_pending_input_bytes_per_session: 16777216
|
||||
max_pending_turns_per_session: 4
|
||||
# Matches the configured Stage0/Stage1 sequence capacity below.
|
||||
max_sessions: 2
|
||||
completed_append_cache_size: 256
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 2
|
||||
async_scheduling: false
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 2
|
||||
async_scheduling: false
|
||||
# The streaming talker advances Python-owned per-request generators on
|
||||
# every step, so only fixed-topology model regions are graph-capturable.
|
||||
compilation_config:
|
||||
cudagraph_mode: PIECEWISE
|
||||
default_sampling_params:
|
||||
max_tokens: 4096
|
||||
stop_token_ids: [151645]
|
||||
@@ -771,8 +771,12 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
self.waiting_for_chunk_running_requests = deque(
|
||||
request for request in self.waiting_for_chunk_running_requests if request.request_id not in request_ids
|
||||
)
|
||||
self._held_non_active = deque(
|
||||
request for request in self._held_non_active if request.request_id not in request_ids
|
||||
)
|
||||
|
||||
for req_id in request_ids:
|
||||
self._active_streams.pop(req_id, None)
|
||||
self.requests_with_ready_chunks.discard(req_id)
|
||||
self.finished_requests.discard(req_id)
|
||||
self._finished_load_reqs.discard(req_id)
|
||||
|
||||
@@ -75,6 +75,10 @@ class OmniEngineCoreRequest(EngineCoreRequest):
|
||||
|
||||
# Optional additional information dictionary (serialized)
|
||||
additional_information: AdditionalInformationPayload | None = None
|
||||
# Runner-owned runtime payload. This is materialized directly into
|
||||
# GPUModelRunner.model_intermediate_buffer instead of using the deprecated
|
||||
# additional_information request transport.
|
||||
model_intermediate_buffer: dict[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
@@ -83,6 +87,7 @@ class OmniEngineCoreRequest(EngineCoreRequest):
|
||||
*,
|
||||
prompt_embeds: torch.Tensor | None = None,
|
||||
additional_information: AdditionalInformationPayload | None = None,
|
||||
model_intermediate_buffer: dict[str, Any] | None = None,
|
||||
) -> "OmniEngineCoreRequest":
|
||||
"""Clone an EngineCoreRequest into an OmniEngineCoreRequest with optional payload overrides."""
|
||||
|
||||
@@ -90,6 +95,8 @@ class OmniEngineCoreRequest(EngineCoreRequest):
|
||||
prompt_embeds = request.prompt_embeds
|
||||
if additional_information is None:
|
||||
additional_information = getattr(request, "additional_information", None)
|
||||
if model_intermediate_buffer is None:
|
||||
model_intermediate_buffer = getattr(request, "model_intermediate_buffer", None)
|
||||
|
||||
return cls(
|
||||
request_id=request.request_id,
|
||||
@@ -113,6 +120,7 @@ class OmniEngineCoreRequest(EngineCoreRequest):
|
||||
reasoning_parser_kwargs=request.reasoning_parser_kwargs,
|
||||
abort_immediately=request.abort_immediately,
|
||||
additional_information=additional_information,
|
||||
model_intermediate_buffer=model_intermediate_buffer,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Stateless request and shutdown helpers for :mod:`async_omni_engine`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import janus
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.engine import EngineCoreRequest
|
||||
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
from vllm_omni.engine.messages import EngineQueueMessage, ShutdownRequestMessage
|
||||
from vllm_omni.engine.rpc_result_router import CorrelatedRpcClient
|
||||
from vllm_omni.engine.serialization import (
|
||||
deserialize_additional_information,
|
||||
serialize_additional_information,
|
||||
)
|
||||
from vllm_omni.engine.stage_runtime import StageRuntime
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
SHUTDOWN_ENQUEUE_TIMEOUT_S = 1.0
|
||||
SHUTDOWN_JOIN_TIMEOUT_S = 30.0
|
||||
_WEAK_SHUTDOWN_JOIN_TIMEOUT_S = 1.0
|
||||
|
||||
|
||||
def inject_global_id(target: Any, request_id: str) -> None:
|
||||
"""Inject ``global_request_id`` into a prompt's additional information."""
|
||||
if isinstance(target, dict):
|
||||
if "additional_information" not in target:
|
||||
target["additional_information"] = {}
|
||||
if target["additional_information"] is None:
|
||||
target["additional_information"] = {}
|
||||
if isinstance(target["additional_information"], dict):
|
||||
target["additional_information"]["global_request_id"] = [str(request_id)]
|
||||
|
||||
|
||||
def upgrade_to_omni_request(
|
||||
request: EngineCoreRequest,
|
||||
raw_prompt: Any,
|
||||
) -> EngineCoreRequest:
|
||||
"""Restore omni-only fields omitted by the upstream input processor."""
|
||||
prompt_embeds = request.prompt_embeds
|
||||
additional_information = None
|
||||
wire_payload = None
|
||||
model_intermediate_buffer = None
|
||||
|
||||
if isinstance(raw_prompt, dict):
|
||||
if prompt_embeds is None:
|
||||
raw_prompt_embeds = raw_prompt.get("prompt_embeds")
|
||||
if isinstance(raw_prompt_embeds, torch.Tensor):
|
||||
prompt_embeds = raw_prompt_embeds
|
||||
raw_info = raw_prompt.get("additional_information")
|
||||
raw_buffer = raw_prompt.get("model_intermediate_buffer")
|
||||
if isinstance(raw_info, dict):
|
||||
wire_payload = dict(raw_info)
|
||||
if isinstance(raw_buffer, dict):
|
||||
model_intermediate_buffer = raw_buffer
|
||||
additional_information = serialize_additional_information(
|
||||
wire_payload,
|
||||
log_prefix="AsyncOmniEngine",
|
||||
)
|
||||
|
||||
if prompt_embeds is None and additional_information is None and model_intermediate_buffer is None:
|
||||
return request
|
||||
|
||||
return OmniEngineCoreRequest.from_request(
|
||||
request,
|
||||
prompt_embeds=prompt_embeds,
|
||||
additional_information=additional_information,
|
||||
model_intermediate_buffer=model_intermediate_buffer,
|
||||
)
|
||||
|
||||
|
||||
def apply_omni_final_stage_metadata(
|
||||
request: EngineCoreRequest,
|
||||
final_stage_id: int,
|
||||
) -> EngineCoreRequest:
|
||||
"""Tag a request so the AR scheduler can skip DiT KV for stage zero."""
|
||||
merged: dict[str, Any] = {}
|
||||
if isinstance(request, OmniEngineCoreRequest) and request.additional_information is not None:
|
||||
merged = deserialize_additional_information(request.additional_information)
|
||||
merged["omni_final_stage_id"] = final_stage_id
|
||||
payload = serialize_additional_information(merged)
|
||||
return OmniEngineCoreRequest.from_request(
|
||||
request,
|
||||
additional_information=payload,
|
||||
)
|
||||
|
||||
|
||||
def weak_shutdown_async_omni_engine(
|
||||
orchestrator_thread: threading.Thread | None,
|
||||
request_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
output_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
rpc_output_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
rpc_client: CorrelatedRpcClient | None,
|
||||
) -> None:
|
||||
"""Best-effort orchestrator cleanup for garbage-collection finalization."""
|
||||
request_queue_closed = False
|
||||
shutdown_enqueued = enqueue_orchestrator_shutdown(
|
||||
request_queue,
|
||||
timeout=SHUTDOWN_ENQUEUE_TIMEOUT_S,
|
||||
)
|
||||
if request_queue is not None and not shutdown_enqueued:
|
||||
try:
|
||||
request_queue.close()
|
||||
request_queue_closed = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if rpc_client is not None:
|
||||
rpc_client.close()
|
||||
|
||||
try:
|
||||
if orchestrator_thread is not None and orchestrator_thread.is_alive():
|
||||
orchestrator_thread.join(timeout=_WEAK_SHUTDOWN_JOIN_TIMEOUT_S)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for q in (request_queue, output_queue, rpc_output_queue):
|
||||
try:
|
||||
if q is not None and not (q is request_queue and request_queue_closed):
|
||||
q.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def enqueue_orchestrator_shutdown(
|
||||
request_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
*,
|
||||
timeout: float,
|
||||
) -> bool:
|
||||
"""Deliver shutdown without waiting forever behind a full request queue."""
|
||||
if request_queue is None:
|
||||
return False
|
||||
try:
|
||||
request_queue.sync_q.put(ShutdownRequestMessage(), timeout=timeout)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def shutdown_runtime_after_orchestrator(
|
||||
orchestrator_thread: threading.Thread,
|
||||
runtime: StageRuntime,
|
||||
) -> None:
|
||||
"""Release a stage runtime once its orchestrator can no longer use it."""
|
||||
try:
|
||||
orchestrator_thread.join()
|
||||
except Exception:
|
||||
logger.exception("[AsyncOmniEngine] Failed while awaiting deferred Orchestrator shutdown")
|
||||
if orchestrator_thread.is_alive():
|
||||
logger.error("[AsyncOmniEngine] Orchestrator is still alive; StageRuntime cleanup remains deferred")
|
||||
return
|
||||
try:
|
||||
runtime.shutdown()
|
||||
except Exception:
|
||||
logger.exception("[AsyncOmniEngine] Failed to shutdown deferred StageRuntime")
|
||||
@@ -18,7 +18,7 @@ import uuid
|
||||
import weakref
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Literal, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
import janus
|
||||
import torch
|
||||
@@ -31,10 +31,23 @@ from vllm.v1.engine import EngineCoreRequest
|
||||
from vllm.v1.engine.input_processor import InputProcessor
|
||||
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.stage_config import strip_parent_engine_args
|
||||
from vllm_omni.config.stage_config import (
|
||||
DuplexSessionRuntimeConfig,
|
||||
load_deploy_config,
|
||||
strip_parent_engine_args,
|
||||
)
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig, parse_attention_config
|
||||
from vllm_omni.diffusion.diffusion_engine import supports_audio_output
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
from vllm_omni.engine.async_engine_utils import (
|
||||
SHUTDOWN_ENQUEUE_TIMEOUT_S,
|
||||
SHUTDOWN_JOIN_TIMEOUT_S,
|
||||
apply_omni_final_stage_metadata,
|
||||
enqueue_orchestrator_shutdown,
|
||||
inject_global_id,
|
||||
shutdown_runtime_after_orchestrator,
|
||||
upgrade_to_omni_request,
|
||||
weak_shutdown_async_omni_engine,
|
||||
)
|
||||
from vllm_omni.engine.messages import (
|
||||
AbortRequestMessage,
|
||||
AddCompanionRequestMessage,
|
||||
@@ -42,14 +55,10 @@ from vllm_omni.engine.messages import (
|
||||
CollectiveRPCResultMessage,
|
||||
EngineQueueMessage,
|
||||
ErrorMessage,
|
||||
ShutdownRequestMessage,
|
||||
StageSubmissionMessage,
|
||||
)
|
||||
from vllm_omni.engine.orchestrator import Orchestrator
|
||||
from vllm_omni.engine.serialization import (
|
||||
deserialize_additional_information,
|
||||
serialize_additional_information,
|
||||
)
|
||||
from vllm_omni.engine.rpc_result_router import CorrelatedRpcClient
|
||||
from vllm_omni.engine.stage_client import StageClient
|
||||
from vllm_omni.engine.stage_init_utils import build_stage0_input_processor
|
||||
from vllm_omni.engine.stage_pool import StagePool
|
||||
@@ -67,10 +76,13 @@ from vllm_omni.metrics.prometheus import OmniRequestCounter
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import DuplexControlClient
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
_STARTUP_POLL_INTERVAL_S = 1.0
|
||||
_REQUEST_QUEUE_MAXSIZE = 256
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Parent-EngineArgs field-routing contracts (consumed by
|
||||
# AsyncOmniEngine._strip_parent_engine_args when ``stage_configs_path`` is set).
|
||||
@@ -106,88 +118,6 @@ _PARENT_ARGS_STRIP: frozenset[str] = frozenset({"stage_configs_path"})
|
||||
_PARENT_ARGS_NO_WARN: frozenset[str] = frozenset({"model"})
|
||||
|
||||
|
||||
def _inject_global_id(target: Any, request_id: str) -> None:
|
||||
"""Inject global_request_id into a prompt dict's additional_information."""
|
||||
if isinstance(target, dict):
|
||||
if "additional_information" not in target:
|
||||
target["additional_information"] = {}
|
||||
if target["additional_information"] is None:
|
||||
target["additional_information"] = {}
|
||||
if isinstance(target["additional_information"], dict):
|
||||
target["additional_information"]["global_request_id"] = [str(request_id)]
|
||||
|
||||
|
||||
def _upgrade_to_omni_request(
|
||||
request: EngineCoreRequest,
|
||||
raw_prompt: Any,
|
||||
) -> EngineCoreRequest:
|
||||
"""Restore omni-only fields omitted by upstream InputProcessor."""
|
||||
prompt_embeds = request.prompt_embeds
|
||||
additional_information = None
|
||||
|
||||
if isinstance(raw_prompt, dict):
|
||||
if prompt_embeds is None:
|
||||
raw_prompt_embeds = raw_prompt.get("prompt_embeds")
|
||||
if isinstance(raw_prompt_embeds, torch.Tensor):
|
||||
prompt_embeds = raw_prompt_embeds
|
||||
additional_information = serialize_additional_information(
|
||||
raw_prompt.get("additional_information"),
|
||||
log_prefix="AsyncOmniEngine",
|
||||
)
|
||||
|
||||
if prompt_embeds is None and additional_information is None:
|
||||
return request
|
||||
|
||||
return OmniEngineCoreRequest.from_request(
|
||||
request,
|
||||
prompt_embeds=prompt_embeds,
|
||||
additional_information=additional_information,
|
||||
)
|
||||
|
||||
|
||||
def _apply_omni_final_stage_metadata(
|
||||
request: EngineCoreRequest,
|
||||
final_stage_id: int,
|
||||
) -> EngineCoreRequest:
|
||||
"""Tag EngineCoreRequest so OmniARScheduler can skip DiT KV when final_stage_id is 0."""
|
||||
merged: dict[str, Any] = {}
|
||||
if isinstance(request, OmniEngineCoreRequest) and request.additional_information is not None:
|
||||
merged = deserialize_additional_information(request.additional_information)
|
||||
merged["omni_final_stage_id"] = final_stage_id
|
||||
payload = serialize_additional_information(merged)
|
||||
return OmniEngineCoreRequest.from_request(
|
||||
request,
|
||||
additional_information=payload,
|
||||
)
|
||||
|
||||
|
||||
def _weak_shutdown_async_omni_engine(
|
||||
orchestrator_thread: threading.Thread | None,
|
||||
request_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
output_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
rpc_output_queue: janus.Queue[EngineQueueMessage] | None,
|
||||
) -> None:
|
||||
"""Best-effort orchestrator cleanup for GC finalization."""
|
||||
try:
|
||||
if request_queue is not None:
|
||||
request_queue.sync_q.put_nowait(ShutdownRequestMessage())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if orchestrator_thread is not None and orchestrator_thread.is_alive():
|
||||
orchestrator_thread.join()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for q in (request_queue, output_queue, rpc_output_queue):
|
||||
try:
|
||||
if q is not None:
|
||||
q.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class AsyncOmniEngine:
|
||||
"""Thin proxy that launches an Orchestrator in a background thread.
|
||||
|
||||
@@ -282,11 +212,22 @@ class AsyncOmniEngine:
|
||||
# restriction beforehand. TODO (Alex) make this cleaner and refactor
|
||||
# stage config resolution to remove kwargs hacks.
|
||||
deploy_config_path = kwargs.get("deploy_config")
|
||||
self.endpoint_restrictions = StageConfigFactory.get_pipeline_endpoint_restrictions(
|
||||
pipeline_config = StageConfigFactory.get_pipeline_config(
|
||||
model=model,
|
||||
trust_remote_code=trust_remote_code,
|
||||
deploy_config_path=deploy_config_path,
|
||||
)
|
||||
self.endpoint_restrictions = pipeline_config.endpoint_restrictions if pipeline_config is not None else ()
|
||||
self._duplex_runtime_extension_path = (
|
||||
pipeline_config.duplex_runtime_extension if pipeline_config is not None else None
|
||||
)
|
||||
self.duplex_serving_adapter_path = (
|
||||
pipeline_config.duplex_serving_adapter if pipeline_config is not None else None
|
||||
)
|
||||
self._duplex_control_enabled = bool(pipeline_config and pipeline_config.duplex_control_enabled)
|
||||
self.duplex_session_config = DuplexSessionRuntimeConfig()
|
||||
if deploy_config_path is not None:
|
||||
self.duplex_session_config = load_deploy_config(deploy_config_path).duplex_session
|
||||
|
||||
kwargs["trust_remote_code"] = trust_remote_code
|
||||
self.config_path, self.stage_configs = self._resolve_stage_configs(
|
||||
@@ -315,7 +256,8 @@ class AsyncOmniEngine:
|
||||
self.rpc_output_queue: janus.Queue[EngineQueueMessage] = janus.Queue()
|
||||
self._shutdown_called = False
|
||||
self._weak_finalizer: weakref.finalize | None = None
|
||||
self._rpc_lock = threading.Lock()
|
||||
self._correlated_rpc_client: CorrelatedRpcClient | None = None
|
||||
self._duplex_control_client: DuplexControlClient | None = None
|
||||
self._running_counter = OmniRequestCounter()
|
||||
|
||||
logger.info(f"[AsyncOmniEngine] Launching Orchestrator thread with {self.num_stages} stages")
|
||||
@@ -334,15 +276,20 @@ class AsyncOmniEngine:
|
||||
)
|
||||
self.orchestrator_thread.start()
|
||||
self._wait_for_orchestrator_init(startup_future, startup_timeout)
|
||||
self._correlated_rpc_client = CorrelatedRpcClient(
|
||||
self.request_queue.sync_q,
|
||||
self.rpc_output_queue.sync_q,
|
||||
)
|
||||
|
||||
# Stage runtime fields are assigned directly on self by the bootstrap thread.
|
||||
self._weak_finalizer = weakref.finalize(
|
||||
self,
|
||||
_weak_shutdown_async_omni_engine,
|
||||
weak_shutdown_async_omni_engine,
|
||||
self.orchestrator_thread,
|
||||
self.request_queue,
|
||||
self.output_queue,
|
||||
self.rpc_output_queue,
|
||||
self._correlated_rpc_client,
|
||||
)
|
||||
|
||||
logger.info(f"[AsyncOmniEngine] Orchestrator ready with {self.num_stages} stages")
|
||||
@@ -435,6 +382,21 @@ class AsyncOmniEngine:
|
||||
pd_config = self._detect_pd_config()
|
||||
|
||||
membership_controller = self._runtime.create_membership_controller()
|
||||
duplex_runtime_extension = None
|
||||
if self._duplex_control_enabled:
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
|
||||
load_duplex_runtime_extension,
|
||||
validate_duplex_runtime_extension,
|
||||
)
|
||||
|
||||
duplex_runtime_extension = load_duplex_runtime_extension(
|
||||
getattr(self, "_duplex_runtime_extension_path", None)
|
||||
)
|
||||
if duplex_runtime_extension is not None:
|
||||
validate_duplex_runtime_extension(
|
||||
duplex_runtime_extension,
|
||||
sampling_defaults=tuple(pool.stage_client.default_sampling_params for pool in self.stage_pools),
|
||||
)
|
||||
|
||||
orchestrator = Orchestrator(
|
||||
request_async_queue=self.request_queue.async_q,
|
||||
@@ -448,6 +410,9 @@ class AsyncOmniEngine:
|
||||
transfer_emitter=self._transfer_emitter,
|
||||
log_stats=self._log_stats,
|
||||
enable_orch_monitor=self._enable_orch_monitor,
|
||||
duplex_runtime_extension=duplex_runtime_extension,
|
||||
enable_duplex_control=self._duplex_control_enabled,
|
||||
duplex_session_config=self.duplex_session_config,
|
||||
)
|
||||
if not startup_future.done():
|
||||
startup_future.set_result(asyncio.get_running_loop())
|
||||
@@ -718,10 +683,10 @@ class AsyncOmniEngine:
|
||||
if stage_type != "diffusion" and not isinstance(prompt, EngineCoreRequest):
|
||||
# Inject global_request_id into the raw prompt.
|
||||
if isinstance(prompt, dict):
|
||||
_inject_global_id(prompt, request_id)
|
||||
inject_global_id(prompt, request_id)
|
||||
elif isinstance(prompt, list):
|
||||
for item in prompt:
|
||||
_inject_global_id(item, request_id)
|
||||
inject_global_id(item, request_id)
|
||||
|
||||
preselected_stage0_replica = self._scope_stage0_multimodal_cache_to_replica(
|
||||
request_id,
|
||||
@@ -751,7 +716,7 @@ class AsyncOmniEngine:
|
||||
_preprocess_ms = (time.perf_counter() - _t_preprocess) * 1000.0
|
||||
# TODO (Peiqi): add this for Qwen3-TTS only. Other models don't have
|
||||
# additional_information field in the prompt.
|
||||
request = _upgrade_to_omni_request(request, prompt)
|
||||
request = upgrade_to_omni_request(request, prompt)
|
||||
|
||||
if reasoning_ended is not None:
|
||||
request.reasoning_ended = reasoning_ended
|
||||
@@ -763,7 +728,7 @@ class AsyncOmniEngine:
|
||||
# to match the key used in Orchestrator.request_states so that
|
||||
# output routing (output.request_id lookup) can find the req_state.
|
||||
request.external_req_id = request_id
|
||||
request = _apply_omni_final_stage_metadata(request, final_stage_id)
|
||||
request = apply_omni_final_stage_metadata(request, final_stage_id)
|
||||
|
||||
# Registration with stage 0's output processor is deferred to the
|
||||
# orchestrator thread (see Orchestrator._handle_add_request), which
|
||||
@@ -811,7 +776,7 @@ class AsyncOmniEngine:
|
||||
companion_params, companion_spl = ep.apply_overrides(stage0_params, sampling_params_list)
|
||||
|
||||
if isinstance(companion_prompt, dict):
|
||||
_inject_global_id(companion_prompt, cid)
|
||||
inject_global_id(companion_prompt, cid)
|
||||
|
||||
request = self.input_processor.process_inputs(
|
||||
request_id=cid,
|
||||
@@ -1434,6 +1399,268 @@ class AsyncOmniEngine:
|
||||
resumable=resumable,
|
||||
)
|
||||
|
||||
def open_duplex_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
session_mode: str = "duplex",
|
||||
capabilities: dict[str, object] | None = None,
|
||||
session_config: dict[str, object] | None = None,
|
||||
runtime_config: dict[str, object] | None = None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Open an engine-level duplex session."""
|
||||
return self._get_duplex_control_client().open(
|
||||
session_id,
|
||||
session_mode=session_mode,
|
||||
capabilities=capabilities,
|
||||
session_config=session_config,
|
||||
runtime_config=runtime_config,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def open_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
session_mode: str = "duplex",
|
||||
capabilities: dict[str, object] | None = None,
|
||||
session_config: dict[str, object] | None = None,
|
||||
runtime_config: dict[str, object] | None = None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Async wrapper for opening an engine-level duplex session."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.open_duplex_session(
|
||||
session_id,
|
||||
session_mode=session_mode,
|
||||
capabilities=capabilities,
|
||||
session_config=session_config,
|
||||
runtime_config=runtime_config,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
),
|
||||
)
|
||||
|
||||
def append_duplex_input(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
mode: str,
|
||||
payload: object,
|
||||
operation_id: str | None = None,
|
||||
final: bool = False,
|
||||
expected_epoch: int | None = None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Append input to an engine-level duplex session."""
|
||||
return self._get_duplex_control_client().append(
|
||||
session_id,
|
||||
mode=mode,
|
||||
payload=payload,
|
||||
operation_id=operation_id,
|
||||
final=final,
|
||||
expected_epoch=expected_epoch,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def append_duplex_input_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
mode: str,
|
||||
payload: object,
|
||||
operation_id: str | None = None,
|
||||
final: bool = False,
|
||||
expected_epoch: int | None = None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Async wrapper for appending duplex input."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.append_duplex_input(
|
||||
session_id,
|
||||
mode=mode,
|
||||
payload=payload,
|
||||
operation_id=operation_id,
|
||||
final=final,
|
||||
expected_epoch=expected_epoch,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
),
|
||||
)
|
||||
|
||||
def signal_duplex_turn(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
event: str,
|
||||
fence: DuplexFence,
|
||||
next_fence: DuplexFence | None = None,
|
||||
session_config: dict[str, object] | None = None,
|
||||
runtime_config: dict[str, object] | None = None,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Signal an engine-level duplex turn."""
|
||||
return self._get_duplex_control_client().signal(
|
||||
session_id,
|
||||
event=event,
|
||||
fence=fence,
|
||||
next_fence=next_fence,
|
||||
session_config=session_config,
|
||||
runtime_config=runtime_config,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def signal_duplex_turn_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
event: str,
|
||||
fence: DuplexFence,
|
||||
next_fence: DuplexFence | None = None,
|
||||
session_config: dict[str, object] | None = None,
|
||||
runtime_config: dict[str, object] | None = None,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Async wrapper for signaling a duplex turn."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.signal_duplex_turn(
|
||||
session_id,
|
||||
event=event,
|
||||
fence=fence,
|
||||
next_fence=next_fence,
|
||||
session_config=session_config,
|
||||
runtime_config=runtime_config,
|
||||
timeout=timeout,
|
||||
),
|
||||
)
|
||||
|
||||
def close_duplex_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
reason: str = "client_close",
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Close an engine-level duplex session."""
|
||||
return self._get_duplex_control_client().close(
|
||||
session_id,
|
||||
reason=reason,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def touch_duplex_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
activity: DuplexLeaseActivity,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
return self._get_duplex_control_client().touch(
|
||||
session_id,
|
||||
fence=fence,
|
||||
activity=activity,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def touch_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
activity: DuplexLeaseActivity,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.touch_duplex_session(
|
||||
session_id,
|
||||
fence=fence,
|
||||
activity=activity,
|
||||
timeout=timeout,
|
||||
),
|
||||
)
|
||||
|
||||
def resume_duplex_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
expected_lease_generation: int,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
return self._get_duplex_control_client().resume(
|
||||
session_id,
|
||||
fence=fence,
|
||||
expected_lease_generation=expected_lease_generation,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def resume_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
expected_lease_generation: int,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.resume_duplex_session(
|
||||
session_id,
|
||||
fence=fence,
|
||||
expected_lease_generation=expected_lease_generation,
|
||||
timeout=timeout,
|
||||
),
|
||||
)
|
||||
|
||||
def _get_duplex_control_client(self) -> DuplexControlClient:
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import DuplexControlClient
|
||||
|
||||
client = getattr(self, "_duplex_control_client", None)
|
||||
if client is None:
|
||||
transport = getattr(self, "_correlated_rpc_client", None)
|
||||
if transport is None:
|
||||
raise RuntimeError("correlated RPC client is not initialized")
|
||||
client = DuplexControlClient(
|
||||
transport,
|
||||
control_id_factory=lambda: uuid.uuid4().hex,
|
||||
)
|
||||
self._duplex_control_client = client
|
||||
return client
|
||||
|
||||
async def close_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
reason: str = "client_close",
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Async wrapper for closing an engine-level duplex session."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.close_duplex_session(session_id, reason=reason, fence=fence, timeout=timeout),
|
||||
)
|
||||
|
||||
def try_get_output(self, timeout: float = 0.001) -> EngineQueueMessage | None:
|
||||
"""Read one output message from the Orchestrator output queue."""
|
||||
try:
|
||||
@@ -1489,36 +1716,19 @@ class AsyncOmniEngine:
|
||||
stage_ids=stage_ids,
|
||||
)
|
||||
|
||||
with self._rpc_lock:
|
||||
self.request_queue.sync_q.put(msg)
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||
try:
|
||||
result_msg = self.rpc_output_queue.sync_q.get(timeout=remaining)
|
||||
except queue.Empty as exc:
|
||||
raise TimeoutError(f"collective_rpc timed out after {timeout} seconds") from exc
|
||||
|
||||
if isinstance(result_msg, ErrorMessage):
|
||||
raise RuntimeError(result_msg.error)
|
||||
|
||||
if not isinstance(result_msg, CollectiveRPCResultMessage):
|
||||
logger.warning(
|
||||
"[AsyncOmniEngine] Dropping unexpected rpc queue message type=%s",
|
||||
getattr(result_msg, "type", type(result_msg).__name__),
|
||||
)
|
||||
continue
|
||||
|
||||
if result_msg.rpc_id != rpc_id:
|
||||
logger.warning(
|
||||
"[AsyncOmniEngine] Dropping mismatched rpc result rpc_id=%s expected=%s",
|
||||
result_msg.rpc_id,
|
||||
rpc_id,
|
||||
)
|
||||
continue
|
||||
|
||||
return list(result_msg.results)
|
||||
transport = self._correlated_rpc_client
|
||||
if transport is None:
|
||||
raise RuntimeError("correlated RPC client is not initialized")
|
||||
result_msg = transport.execute(
|
||||
("collective", rpc_id),
|
||||
msg,
|
||||
timeout=timeout,
|
||||
timeout_message=f"collective_rpc timed out after {timeout} seconds",
|
||||
block_on_submit=True,
|
||||
)
|
||||
if not isinstance(result_msg, CollectiveRPCResultMessage):
|
||||
raise RuntimeError(f"unexpected collective RPC result type: {type(result_msg).__name__}")
|
||||
return list(result_msg.results)
|
||||
|
||||
async def collective_rpc_async(
|
||||
self,
|
||||
@@ -1555,22 +1765,61 @@ class AsyncOmniEngine:
|
||||
finalizer.detach()
|
||||
|
||||
logger.info("[AsyncOmniEngine] Shutting down Orchestrator")
|
||||
if self.request_queue is not None:
|
||||
self.request_queue.sync_q.put_nowait(ShutdownRequestMessage())
|
||||
if self.is_alive():
|
||||
self.orchestrator_thread.join()
|
||||
request_queue_closed = False
|
||||
shutdown_enqueued = enqueue_orchestrator_shutdown(
|
||||
self.request_queue,
|
||||
timeout=SHUTDOWN_ENQUEUE_TIMEOUT_S,
|
||||
)
|
||||
if self.request_queue is not None and not shutdown_enqueued:
|
||||
logger.error(
|
||||
"[AsyncOmniEngine] Failed to enqueue orchestrator shutdown; "
|
||||
"closing the request queue to wake the request handler"
|
||||
)
|
||||
try:
|
||||
self.request_queue.close()
|
||||
request_queue_closed = True
|
||||
except Exception:
|
||||
logger.exception("[AsyncOmniEngine] Failed to close the request queue")
|
||||
|
||||
if self._correlated_rpc_client is not None:
|
||||
try:
|
||||
self._correlated_rpc_client.close()
|
||||
except Exception:
|
||||
logger.exception("[AsyncOmniEngine] Failed to close correlated RPC client")
|
||||
|
||||
orchestrator_stopped = False
|
||||
try:
|
||||
if self.is_alive():
|
||||
self.orchestrator_thread.join(timeout=SHUTDOWN_JOIN_TIMEOUT_S)
|
||||
orchestrator_stopped = not self.is_alive()
|
||||
if not orchestrator_stopped:
|
||||
logger.error(
|
||||
"[AsyncOmniEngine] Orchestrator did not stop within %.1f seconds; continuing cleanup",
|
||||
SHUTDOWN_JOIN_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[AsyncOmniEngine] Failed to join Orchestrator thread")
|
||||
|
||||
for q in (self.request_queue, self.output_queue, self.rpc_output_queue):
|
||||
try:
|
||||
q.close()
|
||||
if not (q is self.request_queue and request_queue_closed):
|
||||
q.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(self, "_runtime") and self._runtime is not None:
|
||||
if hasattr(self, "_runtime") and self._runtime is not None and orchestrator_stopped:
|
||||
try:
|
||||
self._runtime.shutdown()
|
||||
except Exception:
|
||||
logger.exception("[AsyncOmniEngine] Failed to shutdown StageRuntime")
|
||||
elif hasattr(self, "_runtime") and self._runtime is not None:
|
||||
logger.warning("[AsyncOmniEngine] Deferring StageRuntime shutdown until the Orchestrator exits")
|
||||
threading.Thread(
|
||||
target=shutdown_runtime_after_orchestrator,
|
||||
args=(self.orchestrator_thread, self._runtime),
|
||||
daemon=True,
|
||||
name="omni-stage-runtime-shutdown",
|
||||
).start()
|
||||
|
||||
# ── Release CuMem allocator memory pool ──────────────────────────────
|
||||
# When enable_sleep_mode is in use, the CuMem (CUDA Virtual Memory
|
||||
|
||||
@@ -157,7 +157,7 @@ class MembershipController:
|
||||
input_addr = StagePool._client_input_addr(client)
|
||||
if input_addr is None:
|
||||
raise RuntimeError(f"remote replica factory for stage {stage_id} produced a client without input address")
|
||||
pool.add_client(input_addr, client)
|
||||
pool.add_client(input_addr, client, replica_id=replica_id)
|
||||
logger.info(
|
||||
"[MembershipController] attached remote replica stage=%d replica=%d addr=%s",
|
||||
stage_id,
|
||||
|
||||
@@ -106,3 +106,7 @@ class CollectiveRPCResultMessage(EngineQueueMessage, kw_only=True):
|
||||
method: str
|
||||
stage_ids: list[int]
|
||||
results: list[object]
|
||||
|
||||
@property
|
||||
def rpc_correlation_key(self) -> tuple[str, str]:
|
||||
return ("collective", self.rpc_id)
|
||||
@@ -13,8 +13,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time as _time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import janus
|
||||
import torch
|
||||
@@ -22,11 +23,12 @@ from vllm.config import ModelConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.outputs import CompletionOutput, RequestOutput
|
||||
from vllm.pooling_params import PoolingParams
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.sampling_params import RequestOutputKind, SamplingParams
|
||||
from vllm.v1.engine import EngineCoreOutputs
|
||||
from vllm.v1.engine.exceptions import EngineDeadError
|
||||
from vllm.v1.metrics.stats import IterationStats
|
||||
|
||||
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
|
||||
from vllm_omni.engine.membership_controller import MembershipController
|
||||
@@ -53,6 +55,23 @@ from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
DuplexControlPlanePort,
|
||||
DuplexOutputContext,
|
||||
DuplexOutputDecision,
|
||||
DuplexRequestIdentity,
|
||||
DuplexRuntimeExtension,
|
||||
DuplexStageRequestContext,
|
||||
DuplexStageSubmission,
|
||||
DuplexStageSubmissionResult,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_session import (
|
||||
DuplexSessionRuntimeManager,
|
||||
DuplexSessionRuntimeState,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
|
||||
def _build_terminal_empty_output(
|
||||
request_id: str,
|
||||
@@ -141,8 +160,13 @@ def build_engine_core_request_from_tokens(
|
||||
pooling_params = params.clone()
|
||||
|
||||
prompt_embeds: torch.Tensor | None = prompt.get("prompt_embeds")
|
||||
raw_additional_information = prompt.get("additional_information")
|
||||
model_intermediate_buffer = prompt.get("model_intermediate_buffer")
|
||||
wire_payload: dict[str, Any] | None = None
|
||||
if isinstance(raw_additional_information, dict):
|
||||
wire_payload = dict(raw_additional_information)
|
||||
additional_info_payload = serialize_additional_information(
|
||||
prompt.get("additional_information"),
|
||||
wire_payload,
|
||||
log_prefix=f"build_engine_core_request_from_tokens req={request_id}",
|
||||
)
|
||||
|
||||
@@ -159,6 +183,7 @@ def build_engine_core_request_from_tokens(
|
||||
prompt_embeds=prompt_embeds,
|
||||
resumable=resumable,
|
||||
additional_information=additional_info_payload,
|
||||
model_intermediate_buffer=model_intermediate_buffer if isinstance(model_intermediate_buffer, dict) else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,6 +211,10 @@ class OrchestratorRequestState:
|
||||
|
||||
# Per-request pipeline timing accumulator (milliseconds)
|
||||
pipeline_timings: dict[str, float] = field(default_factory=dict)
|
||||
duplex_identity: DuplexRequestIdentity | None = None
|
||||
duplex_stage_fences: dict[int, DuplexFence] = field(default_factory=dict)
|
||||
duplex_config_generation: int = -1
|
||||
running_counter_registered: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -194,10 +223,124 @@ class StreamingInputState:
|
||||
enabled: bool = False
|
||||
# Flag of segment of streaming input finished
|
||||
segment_finished: bool = False
|
||||
# Tokens from the current raw segment boundary. The vLLM output processor
|
||||
# does not guarantee that EngineCoreOutput.new_token_ids survives on the
|
||||
# processed RequestOutput used by the routing layer.
|
||||
segment_token_ids: list[int] = field(default_factory=list)
|
||||
segment_output_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
# Streaming update prompt length
|
||||
new_prompt_len_snapshot: int | None = None
|
||||
# Model/bridge-specific runtime states (e.g., thinker->talker)
|
||||
bridge_states: dict[str, Any] = field(default_factory=dict)
|
||||
# Synchronous stage-transition capability installed by the orchestrator
|
||||
# while the downstream input processor consumes upstream token output.
|
||||
source_token_decoder: Callable[..., str] | None = None
|
||||
|
||||
|
||||
class _OrchestratorDuplexStagePort:
|
||||
"""Adapts generic stage pools to the model-neutral duplex control plane."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stage_pools: list[StagePool],
|
||||
request_states: dict[str, OrchestratorRequestState],
|
||||
running_counter: OmniRequestCounter | None,
|
||||
cleanup_request_ids: Callable[..., Any],
|
||||
) -> None:
|
||||
self._stage_pools = stage_pools
|
||||
self._request_states = request_states
|
||||
self._running_counter = running_counter
|
||||
self._cleanup_request_ids = cleanup_request_ids
|
||||
|
||||
@property
|
||||
def stage_count(self) -> int:
|
||||
return len(self._stage_pools)
|
||||
|
||||
def sampling_defaults(self) -> tuple[object, ...]:
|
||||
return tuple(pool.stage_client.default_sampling_params for pool in self._stage_pools)
|
||||
|
||||
@staticmethod
|
||||
def _sync_bridge_state(
|
||||
request_state: OrchestratorRequestState,
|
||||
context: DuplexStageRequestContext,
|
||||
) -> None:
|
||||
duplex_state = request_state.streaming.bridge_states.setdefault("duplex", {})
|
||||
if not isinstance(duplex_state, dict):
|
||||
duplex_state = {}
|
||||
request_state.streaming.bridge_states["duplex"] = duplex_state
|
||||
previous_epoch = duplex_state.get("epoch")
|
||||
if not isinstance(duplex_state.get("model_turn_id"), int) or previous_epoch != context.fence.epoch:
|
||||
duplex_state["model_turn_id"] = context.fence.turn_id
|
||||
duplex_state.update(
|
||||
{
|
||||
"session_id": context.session_id,
|
||||
"fence": context.fence,
|
||||
"incarnation": context.fence.incarnation,
|
||||
"epoch": context.fence.epoch,
|
||||
"turn_id": context.fence.turn_id,
|
||||
"response_seq": context.fence.response_seq,
|
||||
"session_config": dict(context.session_config),
|
||||
"runtime_config": dict(context.runtime_config),
|
||||
}
|
||||
)
|
||||
|
||||
def ensure_request(self, context: DuplexStageRequestContext) -> None:
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import DuplexRequestIdentity
|
||||
|
||||
request_state = self._request_states.get(context.request_id)
|
||||
if request_state is None:
|
||||
request_state = OrchestratorRequestState(
|
||||
request_id=context.request_id,
|
||||
prompt=None,
|
||||
sampling_params_list=list(context.sampling_params),
|
||||
final_stage_id=context.final_stage_id,
|
||||
duplex_config_generation=context.config_generation,
|
||||
)
|
||||
request_state.streaming.enabled = True
|
||||
self._request_states[context.request_id] = request_state
|
||||
elif request_state.duplex_config_generation != context.config_generation:
|
||||
request_state.sampling_params_list = list(context.sampling_params)
|
||||
request_state.duplex_config_generation = context.config_generation
|
||||
request_state.duplex_identity = DuplexRequestIdentity(
|
||||
session_id=context.session_id,
|
||||
fence=context.fence,
|
||||
)
|
||||
self._sync_bridge_state(request_state, context)
|
||||
|
||||
async def submit(self, submission: DuplexStageSubmission) -> DuplexStageSubmissionResult:
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import DuplexStageSubmissionResult
|
||||
|
||||
context = submission.context
|
||||
request_state = self._request_states.get(context.request_id)
|
||||
if request_state is None:
|
||||
raise RuntimeError(f"duplex request was not preregistered: {context.request_id}")
|
||||
request = build_engine_core_request_from_tokens(
|
||||
request_id=context.request_id,
|
||||
prompt=dict(submission.prompt),
|
||||
params=context.stage_sampling_params,
|
||||
model_config=self._stage_pools[context.stage_id].stage_vllm_config.model_config,
|
||||
resumable=True,
|
||||
)
|
||||
request.external_req_id = request.request_id
|
||||
pool = self._stage_pools[context.stage_id]
|
||||
if submission.already_submitted:
|
||||
replica_id = await pool.submit_update(context.request_id, request_state, request)
|
||||
else:
|
||||
replica_id = await pool.submit_initial(context.request_id, request_state, request, prompt_text=None)
|
||||
request_state.duplex_stage_fences[context.stage_id] = context.fence
|
||||
request_state.stage_submit_ts[context.stage_id] = _time.time()
|
||||
if not request_state.running_counter_registered and self._running_counter is not None:
|
||||
self._running_counter.increment()
|
||||
request_state.running_counter_registered = True
|
||||
return DuplexStageSubmissionResult(
|
||||
request_id=context.request_id,
|
||||
stage_id=context.stage_id,
|
||||
replica_id=replica_id,
|
||||
)
|
||||
|
||||
async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None:
|
||||
await self._cleanup_request_ids(request_ids, abort=abort)
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
@@ -208,6 +351,7 @@ class Orchestrator:
|
||||
_running_counter: OmniRequestCounter | None = None
|
||||
_transfer_emitter: Any = None
|
||||
_stat_logger: OmniPrometheusStatLogger | None = None
|
||||
duplex_control_plane: DuplexControlPlanePort | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -223,6 +367,9 @@ class Orchestrator:
|
||||
transfer_emitter: Any = None,
|
||||
log_stats: bool = False,
|
||||
enable_orch_monitor: bool = False,
|
||||
duplex_runtime_extension: DuplexRuntimeExtension | None = None,
|
||||
enable_duplex_control: bool = False,
|
||||
duplex_session_config: DuplexSessionRuntimeConfig | None = None,
|
||||
) -> None:
|
||||
self.request_async_queue = request_async_queue
|
||||
self.output_async_queue = output_async_queue
|
||||
@@ -236,7 +383,7 @@ class Orchestrator:
|
||||
replica_sampler=self._sample_replica_metrics,
|
||||
)
|
||||
for stage_id, pool in enumerate(self.stage_pools):
|
||||
for replica_id in pool.live_replica_ids():
|
||||
for replica_id in pool.available_replica_ids():
|
||||
self._orch_monitor.register_replica(stage_id, replica_id)
|
||||
|
||||
# PD disaggregation state
|
||||
@@ -254,6 +401,33 @@ class Orchestrator:
|
||||
self._cfg_tracker = CfgCompanionTracker()
|
||||
self._stage_input_processors: dict[int, Any] = {}
|
||||
|
||||
self.duplex_control_plane: DuplexControlPlanePort | None = None
|
||||
self._duplex_reaper_interval_s = 1.0
|
||||
if enable_duplex_control:
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_control_plane import DuplexControlPlane
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseConfig
|
||||
|
||||
runtime_session_config = duplex_session_config or DuplexSessionRuntimeConfig()
|
||||
self._duplex_reaper_interval_s = runtime_session_config.reaper_interval_s
|
||||
|
||||
self.duplex_control_plane = DuplexControlPlane(
|
||||
extension=duplex_runtime_extension,
|
||||
stage_port=_OrchestratorDuplexStagePort(
|
||||
stage_pools=self.stage_pools,
|
||||
request_states=self.request_states,
|
||||
running_counter=self._running_counter,
|
||||
cleanup_request_ids=self._cleanup_request_ids,
|
||||
),
|
||||
result_sink=self.rpc_async_queue,
|
||||
lifecycle_sink=self.output_async_queue,
|
||||
lease_config=DuplexLeaseConfig(
|
||||
idle_ttl_s=runtime_session_config.idle_ttl_s,
|
||||
disconnect_grace_s=runtime_session_config.disconnect_grace_s,
|
||||
),
|
||||
max_sessions=runtime_session_config.max_sessions,
|
||||
completed_append_limit=runtime_session_config.completed_append_cache_size,
|
||||
)
|
||||
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._stages_shutdown = False
|
||||
self._fatal_error: str | None = None
|
||||
@@ -327,6 +501,17 @@ class Orchestrator:
|
||||
logger.exception("[Orchestrator] OmniPrometheusStatLogger init failed; metrics wrap disabled")
|
||||
self._stat_logger = None
|
||||
|
||||
@property
|
||||
def duplex_sessions(self) -> DuplexSessionRuntimeManager:
|
||||
if self.duplex_control_plane is None:
|
||||
raise RuntimeError("duplex control plane is disabled")
|
||||
return self.duplex_control_plane.sessions
|
||||
|
||||
def _require_duplex_control_plane(self) -> DuplexControlPlanePort:
|
||||
if self.duplex_control_plane is None:
|
||||
raise RuntimeError("duplex control plane is disabled")
|
||||
return self.duplex_control_plane
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Main entry point for the Orchestrator event loop."""
|
||||
logger.info("[Orchestrator] Starting event loop")
|
||||
@@ -347,6 +532,8 @@ class Orchestrator:
|
||||
membership_watcher = self._membership.start()
|
||||
|
||||
tasks = [request_task, output_task]
|
||||
if self.duplex_control_plane is not None:
|
||||
tasks.append(asyncio.create_task(self._duplex_reaper_loop(), name="orchestrator-duplex-reaper"))
|
||||
if membership_watcher is not None:
|
||||
tasks.append(membership_watcher)
|
||||
|
||||
@@ -361,6 +548,15 @@ class Orchestrator:
|
||||
# During teardown this is expected; the finally block handles
|
||||
# proper cleanup. Do not re-raise.
|
||||
logger.info("[Orchestrator] Engine dead during shutdown: %s", e)
|
||||
if self._fatal_error is None:
|
||||
self._fatal_error = str(e) or "Stage engine died"
|
||||
await self.rpc_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=self._fatal_error or str(e),
|
||||
fatal=True,
|
||||
stage_id=self._fatal_error_stage_id,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[Orchestrator] Fatal error in orchestrator tasks")
|
||||
raise
|
||||
@@ -373,6 +569,8 @@ class Orchestrator:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception:
|
||||
pass
|
||||
if self.duplex_control_plane is not None:
|
||||
await self.duplex_control_plane.shutdown()
|
||||
|
||||
if self._fatal_error is not None:
|
||||
await self._drain_pending_requests_on_fatal()
|
||||
@@ -405,6 +603,8 @@ class Orchestrator:
|
||||
await self._handle_streaming_update(msg)
|
||||
elif msg_type == "add_companion_request":
|
||||
await self._handle_add_companion(msg)
|
||||
elif self.duplex_control_plane is not None and self.duplex_control_plane.accepts(msg):
|
||||
self.duplex_control_plane.dispatch(msg)
|
||||
elif msg_type == "abort":
|
||||
await self._handle_abort(msg)
|
||||
elif msg_type == "collective_rpc":
|
||||
@@ -432,6 +632,21 @@ class Orchestrator:
|
||||
else:
|
||||
logger.warning("[Orchestrator] Unknown message type: %s", msg_type)
|
||||
|
||||
async def _duplex_reaper_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._shutdown_event.wait(),
|
||||
timeout=self._duplex_reaper_interval_s,
|
||||
)
|
||||
except TimeoutError:
|
||||
plane = self.duplex_control_plane
|
||||
if plane is not None:
|
||||
try:
|
||||
await plane.reap_expired()
|
||||
except Exception:
|
||||
logger.exception("[Orchestrator] Duplex expiry cleanup failed; retrying on next tick")
|
||||
|
||||
async def _handle_add_request(self, msg: StageSubmissionMessage) -> None:
|
||||
"""Handle an add_request message from the main thread."""
|
||||
stage_id = 0
|
||||
@@ -466,8 +681,7 @@ class Orchestrator:
|
||||
mm_features=getattr(prompt, "mm_features", None),
|
||||
)
|
||||
self.request_states[request_id] = req_state
|
||||
if self._running_counter is not None:
|
||||
self._running_counter.increment()
|
||||
self._register_running_request(req_state)
|
||||
req_state.streaming.enabled = bool(getattr(prompt, "resumable", False))
|
||||
req_state.stage_submit_ts[stage_id] = _time.time()
|
||||
enqueue_ts = msg.enqueue_ts
|
||||
@@ -590,6 +804,7 @@ class Orchestrator:
|
||||
return
|
||||
for pool in self.stage_pools:
|
||||
await pool.abort_requests(request_ids)
|
||||
pool.release_bindings(request_ids)
|
||||
|
||||
def _release_request_bindings(self, request_ids: list[str]) -> None:
|
||||
"""Release all stage-local route bindings for the given request ids."""
|
||||
@@ -662,7 +877,7 @@ class Orchestrator:
|
||||
idle = True
|
||||
for stage_id in range(self.num_stages):
|
||||
pool = self.stage_pools[stage_id]
|
||||
for replica_id in pool.live_replica_ids():
|
||||
for replica_id in pool.available_replica_ids():
|
||||
if self._shutdown_event.is_set():
|
||||
return
|
||||
|
||||
@@ -686,6 +901,17 @@ class Orchestrator:
|
||||
if req_state is None or not req_state.streaming.enabled:
|
||||
continue
|
||||
req_state.streaming.segment_finished = bool(getattr(eco, "is_segment_finished", False))
|
||||
req_state.streaming.segment_token_ids = (
|
||||
self._coerce_int_list(getattr(eco, "new_token_ids", None))
|
||||
if req_state.streaming.segment_finished
|
||||
else []
|
||||
)
|
||||
raw_mm = self._completion_multimodal_output(eco, None)
|
||||
req_state.streaming.segment_output_metadata = (
|
||||
dict(raw_mm)
|
||||
if req_state.streaming.segment_finished and isinstance(raw_mm, dict)
|
||||
else {}
|
||||
)
|
||||
req_state.streaming.new_prompt_len_snapshot = getattr(
|
||||
eco,
|
||||
"new_prompt_len_snapshot",
|
||||
@@ -715,32 +941,62 @@ class Orchestrator:
|
||||
raise
|
||||
except EngineDeadError as e:
|
||||
logger.error(
|
||||
"[Orchestrator] Stage-%s is dead: %s",
|
||||
"[Orchestrator] Stage-%s replica-%s is dead: %s",
|
||||
stage_id,
|
||||
replica_id,
|
||||
e,
|
||||
)
|
||||
# TODO: Fault handling is intentionally fail-stop at
|
||||
# the orchestrator level today. If one replica in a
|
||||
# logical stage dies, we promote it to `_fatal_error`,
|
||||
# notify requests already admitted to that stage, and
|
||||
# re-raise so `run()` shuts down all stages. This is
|
||||
# conservative but means a single unhealthy replica in
|
||||
# a multi-replica deployment can take down otherwise
|
||||
# healthy replicas in other stages. Revisit this when
|
||||
# adding per-replica fault isolation / eviction.
|
||||
self._fatal_error = str(e)
|
||||
self._fatal_error_stage_id = stage_id
|
||||
for req_id, req_state in list(self.request_states.items()):
|
||||
if stage_id in req_state.stage_submit_ts:
|
||||
affected_request_ids = pool.mark_replica_unavailable(replica_id)
|
||||
closed_sessions = (
|
||||
self.duplex_control_plane.close_sessions_for_request_ids(
|
||||
affected_request_ids,
|
||||
abort=False,
|
||||
)
|
||||
if self.duplex_control_plane is not None
|
||||
else {}
|
||||
)
|
||||
for session_id, stale_request_ids in closed_sessions.items():
|
||||
affected_request_ids.extend(stale_request_ids)
|
||||
logger.warning(
|
||||
"[Orchestrator] closed duplex session %s after stage-%s replica-%s died; "
|
||||
"stale_request_ids=%s",
|
||||
session_id,
|
||||
stage_id,
|
||||
replica_id,
|
||||
stale_request_ids,
|
||||
)
|
||||
affected_request_ids = list(dict.fromkeys(affected_request_ids))
|
||||
if pool.available_replica_ids():
|
||||
for req_id in affected_request_ids:
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=str(e),
|
||||
fatal=True,
|
||||
fatal=False,
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
)
|
||||
self.request_states.pop(req_id, None)
|
||||
await self._cleanup_request_ids(
|
||||
affected_request_ids,
|
||||
close_duplex_sessions=True,
|
||||
)
|
||||
continue
|
||||
|
||||
self._fatal_error = str(e)
|
||||
self._fatal_error_stage_id = stage_id
|
||||
for req_id in affected_request_ids:
|
||||
await self.output_async_queue.put(
|
||||
ErrorMessage(
|
||||
error=str(e),
|
||||
fatal=True,
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
)
|
||||
await self._cleanup_request_ids(
|
||||
affected_request_ids,
|
||||
close_duplex_sessions=True,
|
||||
)
|
||||
self._shutdown_event.set()
|
||||
raise
|
||||
except Exception:
|
||||
@@ -781,7 +1037,8 @@ class Orchestrator:
|
||||
continue
|
||||
|
||||
stage_metrics = None
|
||||
if output.finished:
|
||||
segment_finished = req_state.streaming.enabled and req_state.streaming.segment_finished
|
||||
if output.finished or segment_finished:
|
||||
stage_metrics = pool.build_stage_metrics(
|
||||
[output],
|
||||
submit_ts=req_state.stage_submit_ts.get(stage_id, _time.time()),
|
||||
@@ -811,22 +1068,56 @@ class Orchestrator:
|
||||
await self._cleanup_request_ids(
|
||||
[parent_id, *self._cfg_tracker.cleanup_parent(parent_id)],
|
||||
abort=True,
|
||||
close_duplex_sessions=True,
|
||||
)
|
||||
|
||||
# ---- Shared helpers ----
|
||||
|
||||
async def _cleanup_request_ids(self, request_ids: list[str], *, abort: bool = False) -> None:
|
||||
async def _cleanup_request_ids(
|
||||
self,
|
||||
request_ids: list[str],
|
||||
*,
|
||||
abort: bool = False,
|
||||
close_duplex_sessions: bool = False,
|
||||
) -> None:
|
||||
"""Release pool bindings and logical request state for the given ids."""
|
||||
if not request_ids:
|
||||
return
|
||||
|
||||
if abort:
|
||||
await self._abort_request_ids(request_ids)
|
||||
self._release_request_bindings(request_ids)
|
||||
for request_id in request_ids:
|
||||
self._pd_kv_params.pop(request_id, None)
|
||||
if self.request_states.pop(request_id, None) is not None and self._running_counter is not None:
|
||||
self._running_counter.decrement()
|
||||
cleanup_ids = list(dict.fromkeys(request_ids))
|
||||
closing_session_ids: list[str] = []
|
||||
if close_duplex_sessions and self.duplex_control_plane is not None:
|
||||
closed_sessions = self.duplex_control_plane.close_sessions_for_request_ids(
|
||||
cleanup_ids,
|
||||
abort=abort,
|
||||
cleanup_in_progress=True,
|
||||
)
|
||||
closing_session_ids.extend(closed_sessions)
|
||||
for session_id, stale_request_ids in closed_sessions.items():
|
||||
logger.info(
|
||||
"[Orchestrator] closed duplex session %s while cleaning failed request ids %s",
|
||||
session_id,
|
||||
stale_request_ids,
|
||||
)
|
||||
cleanup_ids.extend(stale_request_ids)
|
||||
cleanup_ids = list(dict.fromkeys(cleanup_ids))
|
||||
|
||||
try:
|
||||
if abort:
|
||||
await self._abort_request_ids(cleanup_ids)
|
||||
self._release_request_bindings(cleanup_ids)
|
||||
for request_id in cleanup_ids:
|
||||
self._pd_kv_params.pop(request_id, None)
|
||||
req_state = self.request_states.pop(request_id, None)
|
||||
if req_state is not None and req_state.running_counter_registered and self._running_counter is not None:
|
||||
self._running_counter.decrement()
|
||||
req_state.running_counter_registered = False
|
||||
except BaseException:
|
||||
if closing_session_ids and self.duplex_control_plane is not None:
|
||||
self.duplex_control_plane.defer_request_cleanups(closing_session_ids)
|
||||
raise
|
||||
if closing_session_ids and self.duplex_control_plane is not None:
|
||||
self.duplex_control_plane.finalize_closed_sessions(closing_session_ids)
|
||||
|
||||
async def _apply_raw_terminal_stage_finish(
|
||||
self,
|
||||
@@ -873,6 +1164,34 @@ class Orchestrator:
|
||||
params.cfg_kv_request_ids = companion_request_ids
|
||||
return params
|
||||
|
||||
def _duplex_session_for_req_state(self, req_state: OrchestratorRequestState) -> DuplexSessionRuntimeState | None:
|
||||
if self.duplex_control_plane is None:
|
||||
return None
|
||||
return self.duplex_control_plane.session_for_identity(req_state.duplex_identity)
|
||||
|
||||
def _record_duplex_stage_submission(
|
||||
self,
|
||||
stage_id: int,
|
||||
request_id: str,
|
||||
replica_id: int,
|
||||
req_state: OrchestratorRequestState,
|
||||
) -> None:
|
||||
del replica_id
|
||||
identity = req_state.duplex_identity
|
||||
session = self._duplex_session_for_req_state(req_state)
|
||||
if identity is None or session is None:
|
||||
return
|
||||
req_state.duplex_stage_fences[stage_id] = identity.fence
|
||||
session.bind_stage_request(stage_id, request_id, fence=identity.fence)
|
||||
req_state.stage_submit_ts[stage_id] = _time.time()
|
||||
self._register_running_request(req_state)
|
||||
|
||||
def _register_running_request(self, req_state: OrchestratorRequestState) -> None:
|
||||
if req_state.running_counter_registered or self._running_counter is None:
|
||||
return
|
||||
self._running_counter.increment()
|
||||
req_state.running_counter_registered = True
|
||||
|
||||
async def _route_output(
|
||||
self,
|
||||
stage_id: int,
|
||||
@@ -885,7 +1204,6 @@ class Orchestrator:
|
||||
req_id = output.request_id
|
||||
finished = output.finished
|
||||
submit_ts = req_state.stage_submit_ts.get(stage_id)
|
||||
|
||||
# CFG companion: stash output so parent can bundle [parent, *companions]
|
||||
# into source_outputs for the bridge (e.g. thinker2imagegen).
|
||||
if finished and self._cfg_tracker.is_companion(req_id):
|
||||
@@ -899,7 +1217,17 @@ class Orchestrator:
|
||||
req_state.finished_final_output_stage_ids.add(stage_id)
|
||||
final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id}
|
||||
request_finished = final_output_stage_ids.issubset(req_state.finished_final_output_stage_ids)
|
||||
if self.stage_pools[stage_id].final_output:
|
||||
# Duplex stage-0 segment boundaries are not client-visible outputs:
|
||||
# direct decisions are emitted by the model runtime extension below,
|
||||
# while spoken content flows through the next stage. Forwarding
|
||||
# the raw stage-0 output as well injects one cumulative-text,
|
||||
# no-audio message per unit that every downstream consumer must
|
||||
# filter out again (the official implementation returns exactly one
|
||||
# result per audio chunk).
|
||||
is_duplex_stage0_segment = (
|
||||
stage_id == 0 and self._is_duplex_session_request(req_state) and req_state.streaming.segment_finished
|
||||
)
|
||||
if self.stage_pools[stage_id].final_output and not is_duplex_stage0_segment:
|
||||
await self.output_async_queue.put(
|
||||
OutputMessage(
|
||||
request_id=req_id,
|
||||
@@ -907,7 +1235,10 @@ class Orchestrator:
|
||||
replica_id=replica_id,
|
||||
engine_outputs=output,
|
||||
metrics=stage_metrics,
|
||||
finished=request_finished,
|
||||
finished=(
|
||||
request_finished
|
||||
or (self._is_duplex_session_request(req_state) and req_state.streaming.segment_finished)
|
||||
),
|
||||
stage_submit_ts=submit_ts,
|
||||
)
|
||||
)
|
||||
@@ -928,6 +1259,18 @@ class Orchestrator:
|
||||
self._pd_kv_params[req_id] = kv_params if isinstance(kv_params, dict) else dict(kv_params)
|
||||
req_state.pd_prefill_multimodal_output = getattr(output, "multimodal_output", None)
|
||||
|
||||
duplex_output_decision = self._duplex_output_decision(stage_id, output, req_state)
|
||||
if duplex_output_decision is not None:
|
||||
await self._emit_duplex_direct_output(
|
||||
stage_id,
|
||||
req_id,
|
||||
output,
|
||||
duplex_output_decision,
|
||||
stage_metrics,
|
||||
submit_ts,
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
(finished or (req_state.streaming.enabled and req_state.streaming.segment_finished))
|
||||
and stage_id < req_state.final_stage_id
|
||||
@@ -941,6 +1284,12 @@ class Orchestrator:
|
||||
):
|
||||
self._cfg_tracker.defer_parent(req_id, output, stage_id)
|
||||
else:
|
||||
stage_params = req_state.sampling_params_list[stage_id]
|
||||
final_only_finished = (
|
||||
req_state.streaming.enabled
|
||||
and finished
|
||||
and getattr(stage_params, "output_kind", None) == RequestOutputKind.FINAL_ONLY
|
||||
)
|
||||
await self._forward_to_next_stage(
|
||||
req_id,
|
||||
stage_id,
|
||||
@@ -948,9 +1297,14 @@ class Orchestrator:
|
||||
req_state,
|
||||
src_replica_id=replica_id,
|
||||
is_streaming_session=req_state.streaming.enabled,
|
||||
is_final_update=False,
|
||||
is_final_update=final_only_finished,
|
||||
)
|
||||
if req_state.streaming.enabled and finished:
|
||||
if (
|
||||
req_state.streaming.enabled
|
||||
and finished
|
||||
and not final_only_finished
|
||||
and not self._is_duplex_session_request(req_state)
|
||||
):
|
||||
# For streaming sessions, send the terminal (resumable=False) update only on a finish
|
||||
await self._forward_to_next_stage(
|
||||
req_id,
|
||||
@@ -962,7 +1316,7 @@ class Orchestrator:
|
||||
is_final_update=True,
|
||||
)
|
||||
|
||||
if request_finished:
|
||||
if request_finished and not self._is_duplex_session_request(req_state):
|
||||
await self._cleanup_request_ids([req_id, *self._cfg_tracker.cleanup_parent(req_id)])
|
||||
|
||||
def _next_stage_already_submitted(self, stage_id: int, req_state: OrchestratorRequestState) -> bool:
|
||||
@@ -1039,6 +1393,137 @@ class Orchestrator:
|
||||
request.external_req_id = req_id
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def _duplex_output_context(
|
||||
req_state: OrchestratorRequestState,
|
||||
*,
|
||||
stage_id: int | None = None,
|
||||
) -> DuplexOutputContext | None:
|
||||
identity = req_state.duplex_identity
|
||||
if identity is None:
|
||||
return None
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
DuplexOutputContext,
|
||||
DuplexRequestIdentity,
|
||||
)
|
||||
|
||||
fence = req_state.duplex_stage_fences.get(stage_id, identity.fence) if stage_id is not None else identity.fence
|
||||
return DuplexOutputContext(
|
||||
identity=DuplexRequestIdentity(
|
||||
session_id=identity.session_id,
|
||||
fence=fence,
|
||||
),
|
||||
final_stage_id=req_state.final_stage_id,
|
||||
segment_finished=req_state.streaming.enabled and req_state.streaming.segment_finished,
|
||||
segment_token_ids=tuple(req_state.streaming.segment_token_ids),
|
||||
segment_output_metadata=req_state.streaming.segment_output_metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_duplex_session_request(req_state: OrchestratorRequestState) -> bool:
|
||||
return req_state.duplex_identity is not None
|
||||
|
||||
@staticmethod
|
||||
def _duplex_fence_for_req_state(
|
||||
req_state: OrchestratorRequestState,
|
||||
*,
|
||||
stage_id: int | None = None,
|
||||
) -> DuplexFence | None:
|
||||
context = Orchestrator._duplex_output_context(req_state, stage_id=stage_id)
|
||||
return context.identity.fence if context is not None else None
|
||||
|
||||
def _duplex_output_decision(
|
||||
self,
|
||||
stage_id: int,
|
||||
output: Any,
|
||||
req_state: OrchestratorRequestState,
|
||||
) -> DuplexOutputDecision | None:
|
||||
if self.duplex_control_plane is None:
|
||||
return None
|
||||
context = self._duplex_output_context(req_state, stage_id=stage_id)
|
||||
decision = self.duplex_control_plane.decide_output(
|
||||
stage_id,
|
||||
output,
|
||||
context,
|
||||
)
|
||||
return decision
|
||||
|
||||
async def _emit_duplex_direct_output(
|
||||
self,
|
||||
stage_id: int,
|
||||
req_id: str,
|
||||
output: Any,
|
||||
decision: DuplexOutputDecision,
|
||||
stage_metrics: Any,
|
||||
submit_ts: float | None,
|
||||
) -> None:
|
||||
action = getattr(decision.action, "value", decision.action)
|
||||
if action != "direct_response":
|
||||
raise ValueError(f"Unsupported duplex output action: {action}")
|
||||
from vllm_omni.experimental.fullduplex.output import attach_duplex_output_decision
|
||||
|
||||
engine_output = attach_duplex_output_decision(
|
||||
OmniRequestOutput(
|
||||
request_id=req_id,
|
||||
finished=True,
|
||||
stage_id=stage_id,
|
||||
final_output_type=decision.final_output_type,
|
||||
request_output=output,
|
||||
),
|
||||
decision,
|
||||
)
|
||||
await self.output_async_queue.put(
|
||||
OutputMessage(
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
engine_outputs=engine_output,
|
||||
metrics=stage_metrics,
|
||||
finished=True,
|
||||
stage_submit_ts=submit_ts,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _completion_multimodal_output(output: Any, completion: Any) -> dict[str, Any]:
|
||||
mm_output = getattr(output, "multimodal_output", None)
|
||||
if isinstance(mm_output, dict):
|
||||
return mm_output
|
||||
mm_output = getattr(completion, "multimodal_output", None) if completion is not None else None
|
||||
return mm_output if isinstance(mm_output, dict) else {}
|
||||
|
||||
@classmethod
|
||||
def _coerce_int_list(cls, value: Any) -> 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 []
|
||||
out: list[int] = []
|
||||
for item in value:
|
||||
token_id = cls._coerce_int(item)
|
||||
if token_id is not None:
|
||||
out.append(token_id)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _coerce_int(value: Any) -> 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
|
||||
|
||||
async def _handle_cfg_companion_ready(self, req_id: str) -> None:
|
||||
"""Mark a CFG companion as done; if all companions are done, flush deferred parent."""
|
||||
parent_id = self._cfg_tracker.on_companion_completed(req_id)
|
||||
@@ -1293,9 +1778,9 @@ class Orchestrator:
|
||||
diffusion_prompt = req_state.prompt
|
||||
|
||||
if already_submitted:
|
||||
await next_pool.submit_update(req_id, req_state, diffusion_prompt)
|
||||
replica_id = await next_pool.submit_update(req_id, req_state, diffusion_prompt)
|
||||
else:
|
||||
await next_pool.submit_initial(
|
||||
replica_id = await next_pool.submit_initial(
|
||||
req_id,
|
||||
req_state,
|
||||
diffusion_prompt,
|
||||
@@ -1307,6 +1792,12 @@ class Orchestrator:
|
||||
},
|
||||
params_override=self._maybe_clone_diffusion_params_for_cfg(req_id, params),
|
||||
)
|
||||
self._record_duplex_stage_submission(
|
||||
next_logical,
|
||||
req_id,
|
||||
replica_id,
|
||||
req_state,
|
||||
)
|
||||
req_state.stage_submit_ts[next_logical] = _time.time()
|
||||
_tx_ms = (_time.perf_counter() - _t_submit_start) * 1000.0
|
||||
self._emit_tx_edge(
|
||||
@@ -1351,9 +1842,15 @@ class Orchestrator:
|
||||
)
|
||||
request.external_req_id = request.request_id
|
||||
if already_submitted:
|
||||
await next_pool.submit_update(req_id, req_state, request)
|
||||
replica_id = await next_pool.submit_update(req_id, req_state, request)
|
||||
else:
|
||||
await next_pool.submit_initial(req_id, req_state, request, prompt_text=None)
|
||||
replica_id = await next_pool.submit_initial(req_id, req_state, request, prompt_text=None)
|
||||
self._record_duplex_stage_submission(
|
||||
next_logical,
|
||||
req_id,
|
||||
replica_id,
|
||||
req_state,
|
||||
)
|
||||
|
||||
req_state.stage_submit_ts[next_logical] = _time.time()
|
||||
_tx_ms = (_time.perf_counter() - _t_submit_start) * 1000.0
|
||||
@@ -1373,6 +1870,13 @@ class Orchestrator:
|
||||
{},
|
||||
)[req_id] = req_state.pd_prefill_multimodal_output
|
||||
|
||||
previous_decoder = req_state.streaming.source_token_decoder
|
||||
source_processor = self.stage_pools[src_stage_id].output_processor
|
||||
tokenizer = getattr(source_processor, "tokenizer", None)
|
||||
decode = getattr(tokenizer, "decode", None)
|
||||
if callable(decode):
|
||||
req_state.streaming.source_token_decoder = decode
|
||||
|
||||
try:
|
||||
next_inputs = next_client.process_engine_inputs(
|
||||
source_outputs,
|
||||
@@ -1386,6 +1890,8 @@ class Orchestrator:
|
||||
next_logical,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
req_state.streaming.source_token_decoder = previous_decoder
|
||||
|
||||
if not next_inputs:
|
||||
if not getattr(output, "finished", False):
|
||||
@@ -1446,9 +1952,15 @@ class Orchestrator:
|
||||
)
|
||||
|
||||
if already_submitted:
|
||||
await next_pool.submit_update(req_id, req_state, request)
|
||||
replica_id = await next_pool.submit_update(req_id, req_state, request)
|
||||
else:
|
||||
await next_pool.submit_initial(req_id, req_state, request, prompt_text=None)
|
||||
replica_id = await next_pool.submit_initial(req_id, req_state, request, prompt_text=None)
|
||||
self._record_duplex_stage_submission(
|
||||
next_logical,
|
||||
req_id,
|
||||
replica_id,
|
||||
req_state,
|
||||
)
|
||||
|
||||
req_state.stage_submit_ts[next_logical] = _time.time()
|
||||
_tx_ms = (_time.perf_counter() - _t_submit_start) * 1000.0
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from typing import TypeAlias
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.engine.messages import (
|
||||
EngineQueueMessage,
|
||||
ErrorMessage,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
RpcCorrelationKey: TypeAlias = tuple[str, str]
|
||||
RpcWaiter: TypeAlias = queue.Queue[EngineQueueMessage]
|
||||
|
||||
|
||||
class RpcResultRouter:
|
||||
"""Single consumer that dispatches shared RPC results by correlation ID."""
|
||||
|
||||
def __init__(self, source_queue) -> None:
|
||||
self._source_queue = source_queue
|
||||
self._pending: dict[RpcCorrelationKey, RpcWaiter] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._stopped = threading.Event()
|
||||
self._terminal_error: ErrorMessage | None = None
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
daemon=True,
|
||||
name="omni-rpc-result-router",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def register(self, key: RpcCorrelationKey) -> RpcWaiter:
|
||||
waiter: RpcWaiter = queue.Queue(maxsize=1)
|
||||
with self._lock:
|
||||
if self._stopped.is_set():
|
||||
raise RuntimeError("RPC result router is closed")
|
||||
if self._terminal_error is not None:
|
||||
raise RuntimeError(self._terminal_error.error)
|
||||
if key in self._pending:
|
||||
raise RuntimeError(f"duplicate pending RPC correlation key: {key!r}")
|
||||
self._pending[key] = waiter
|
||||
return waiter
|
||||
|
||||
def unregister(self, key: RpcCorrelationKey, waiter: RpcWaiter) -> None:
|
||||
with self._lock:
|
||||
if self._pending.get(key) is waiter:
|
||||
self._pending.pop(key, None)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._stopped.is_set():
|
||||
return
|
||||
self._stopped.set()
|
||||
self._broadcast_error(ErrorMessage(error="RPC result router closed", fatal=True))
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stopped.is_set():
|
||||
try:
|
||||
message = self._source_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as exc:
|
||||
if not self._stopped.is_set():
|
||||
logger.exception("RPC result router source queue failed")
|
||||
self._broadcast_error(ErrorMessage(error=str(exc), fatal=True))
|
||||
return
|
||||
|
||||
if isinstance(message, ErrorMessage):
|
||||
if message.fatal:
|
||||
self._broadcast_error(message)
|
||||
else:
|
||||
logger.warning(
|
||||
"Dropping uncorrelated non-fatal RPC error request_id=%s stage_id=%s: %s",
|
||||
message.request_id,
|
||||
message.stage_id,
|
||||
message.error,
|
||||
)
|
||||
continue
|
||||
key = self._correlation_key(message)
|
||||
if key is None:
|
||||
logger.warning(
|
||||
"Dropping unexpected RPC result message type=%s",
|
||||
getattr(message, "type", type(message).__name__),
|
||||
)
|
||||
continue
|
||||
with self._lock:
|
||||
waiter = self._pending.pop(key, None)
|
||||
if waiter is None:
|
||||
logger.warning("Dropping late or unknown RPC result correlation_key=%s", key)
|
||||
continue
|
||||
waiter.put_nowait(message)
|
||||
|
||||
def _broadcast_error(self, message: ErrorMessage) -> None:
|
||||
with self._lock:
|
||||
if message.fatal:
|
||||
self._terminal_error = message
|
||||
waiters = list(self._pending.values())
|
||||
self._pending.clear()
|
||||
for waiter in waiters:
|
||||
try:
|
||||
waiter.put_nowait(message)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _correlation_key(message: EngineQueueMessage) -> RpcCorrelationKey | None:
|
||||
key = getattr(message, "rpc_correlation_key", None)
|
||||
if not isinstance(key, tuple) or len(key) != 2 or not all(isinstance(part, str) and part for part in key):
|
||||
return None
|
||||
return key
|
||||
|
||||
|
||||
class CorrelatedRpcClient:
|
||||
"""Own request submission and correlated result waiting as one lifecycle."""
|
||||
|
||||
def __init__(self, request_queue, result_queue) -> None:
|
||||
self._request_queue = request_queue
|
||||
self._router = RpcResultRouter(result_queue)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
key: RpcCorrelationKey,
|
||||
message: EngineQueueMessage,
|
||||
*,
|
||||
timeout: float | None,
|
||||
timeout_message: str,
|
||||
block_on_submit: bool = False,
|
||||
) -> EngineQueueMessage:
|
||||
waiter = self._router.register(key)
|
||||
try:
|
||||
if block_on_submit:
|
||||
self._request_queue.put(message)
|
||||
else:
|
||||
self._request_queue.put_nowait(message)
|
||||
try:
|
||||
result = waiter.get(timeout=timeout)
|
||||
except queue.Empty as exc:
|
||||
raise TimeoutError(timeout_message) from exc
|
||||
if isinstance(result, ErrorMessage):
|
||||
raise RuntimeError(result.error)
|
||||
return result
|
||||
finally:
|
||||
self._router.unregister(key, waiter)
|
||||
|
||||
def close(self) -> None:
|
||||
self._router.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CorrelatedRpcClient",
|
||||
"RpcCorrelationKey",
|
||||
"RpcResultRouter",
|
||||
"RpcWaiter",
|
||||
]
|
||||
+105
-30
@@ -97,6 +97,7 @@ class StagePool:
|
||||
self._stage_vllm_config = stage_vllm_config
|
||||
self._next_replica_id = 0
|
||||
self._request_bindings: dict[str, int] = {}
|
||||
self._unavailable_replicas: set[int] = set()
|
||||
self._replica_metrics: list[_ReplicaMetrics] = [_ReplicaMetrics() for _ in self.clients]
|
||||
self._output_timestamps_by_request: dict[str, list[float]] = {}
|
||||
self._non_empty_first_output_timestamps_by_request: dict[str, float] = {}
|
||||
@@ -206,27 +207,41 @@ class StagePool:
|
||||
return addr
|
||||
return None
|
||||
|
||||
def add_client(self, input_addr: str, client: Any) -> int:
|
||||
def add_client(self, input_addr: str, client: Any, *, replica_id: int | None = None) -> int:
|
||||
"""Register a head-side client for ``input_addr``.
|
||||
|
||||
Returns the assigned ``replica_id`` (index into :attr:`clients`).
|
||||
If the address is already known, replaces the existing client and
|
||||
returns its existing id (this should not happen in practice — the
|
||||
master server assigns unique slots — but the contract is idempotent
|
||||
to keep the dispatch layer robust).
|
||||
A coordinator-provided ``replica_id`` restores that exact slot after
|
||||
unregister/re-register. Without one, a known address keeps its slot
|
||||
and a new address takes the first vacant slot.
|
||||
"""
|
||||
if not input_addr:
|
||||
raise ValueError("input_addr must be a non-empty string")
|
||||
if replica_id is not None and replica_id < 0:
|
||||
raise ValueError("replica_id must be non-negative")
|
||||
|
||||
existing = self._addr_to_replica_id.get(input_addr)
|
||||
if existing is not None:
|
||||
self.clients[existing] = client
|
||||
return existing
|
||||
if replica_id is not None and existing is not None and existing != replica_id:
|
||||
raise ValueError(
|
||||
f"input address {input_addr!r} is already assigned to replica {existing}, not {replica_id}"
|
||||
)
|
||||
if replica_id is None:
|
||||
replica_id = existing
|
||||
if replica_id is None:
|
||||
replica_id = next(
|
||||
(index for index, existing_client in enumerate(self.clients) if existing_client is None),
|
||||
len(self.clients),
|
||||
)
|
||||
|
||||
replica_id = len(self.clients)
|
||||
self.clients.append(client)
|
||||
while len(self.clients) <= replica_id:
|
||||
self.clients.append(None)
|
||||
self._replica_metrics.append(_ReplicaMetrics())
|
||||
for known_addr, known_replica_id in list(self._addr_to_replica_id.items()):
|
||||
if known_replica_id == replica_id and known_addr != input_addr:
|
||||
self._addr_to_replica_id.pop(known_addr, None)
|
||||
self.clients[replica_id] = client
|
||||
self._addr_to_replica_id[input_addr] = replica_id
|
||||
self._replica_metrics.append(_ReplicaMetrics())
|
||||
self._unavailable_replicas.discard(replica_id)
|
||||
return replica_id
|
||||
|
||||
def remove_client(self, input_addr: str) -> Any | None:
|
||||
@@ -234,7 +249,7 @@ class StagePool:
|
||||
|
||||
Slot is marked ``None`` to preserve indices for outstanding bindings.
|
||||
"""
|
||||
replica_id = self._addr_to_replica_id.pop(input_addr, None)
|
||||
replica_id = self._addr_to_replica_id.get(input_addr)
|
||||
if replica_id is None:
|
||||
return None
|
||||
client = self.clients[replica_id]
|
||||
@@ -475,6 +490,39 @@ class StagePool:
|
||||
for request_id in request_ids:
|
||||
self.release_binding(request_id)
|
||||
|
||||
def release_replica_bindings(self, replica_id: int) -> list[str]:
|
||||
"""Drop all route/session bindings owned by one physical replica."""
|
||||
released_request_ids = [
|
||||
request_id
|
||||
for request_id, bound_replica_id in list(self._request_bindings.items())
|
||||
if bound_replica_id == replica_id
|
||||
]
|
||||
released_request_ids.extend(
|
||||
request_id
|
||||
for request_id, input_addr in list(self._affinity.items())
|
||||
if self._addr_to_replica_id.get(input_addr) == replica_id
|
||||
)
|
||||
released_request_ids = list(dict.fromkeys(released_request_ids))
|
||||
for request_id in released_request_ids:
|
||||
self.release_binding(request_id)
|
||||
return released_request_ids
|
||||
|
||||
def mark_replica_unavailable(self, replica_id: int) -> list[str]:
|
||||
"""Evict a failed replica from admission and release its bindings."""
|
||||
if 0 <= replica_id < self.num_replicas:
|
||||
self._unavailable_replicas.add(replica_id)
|
||||
return self.release_replica_bindings(replica_id)
|
||||
|
||||
def is_replica_available(self, replica_id: int) -> bool:
|
||||
return (
|
||||
0 <= replica_id < self.num_replicas
|
||||
and self.clients[replica_id] is not None
|
||||
and replica_id not in self._unavailable_replicas
|
||||
)
|
||||
|
||||
def available_replica_ids(self) -> list[int]:
|
||||
return [replica_id for replica_id in range(self.num_replicas) if self.is_replica_available(replica_id)]
|
||||
|
||||
def select_replica_id(
|
||||
self,
|
||||
request_id: str,
|
||||
@@ -483,23 +531,27 @@ class StagePool:
|
||||
) -> int:
|
||||
"""Pick a replica id for *request_id* and cache the choice (legacy path)."""
|
||||
cached = self.get_bound_replica_id(request_id)
|
||||
if cached is not None and self.clients[cached] is not None:
|
||||
if cached is not None and self.clients[cached] is not None and self.is_replica_available(cached):
|
||||
return cached
|
||||
if cached is not None:
|
||||
# Cached replica is dead/unavailable: drop the stale binding.
|
||||
self.release_binding(request_id)
|
||||
|
||||
chosen: int | None = None
|
||||
if affinity_request_id is not None:
|
||||
parent = self.get_bound_replica_id(affinity_request_id)
|
||||
if parent is not None and self.clients[parent] is not None:
|
||||
if parent is not None and self.clients[parent] is not None and self.is_replica_available(parent):
|
||||
chosen = parent
|
||||
|
||||
if chosen is None:
|
||||
live = self.live_replica_ids()
|
||||
# Prefer replicas that are both live (client up) and available.
|
||||
live = [r for r in self.live_replica_ids() if self.is_replica_available(r)]
|
||||
if not live:
|
||||
raise RuntimeError(f"stage {self.stage_id} has no live replicas")
|
||||
if len(live) == 1:
|
||||
chosen = live[0]
|
||||
else:
|
||||
# Round-robin over live replicas only.
|
||||
# Round-robin over live, available replicas only.
|
||||
start = self._next_replica_id % len(live)
|
||||
chosen = live[start]
|
||||
self._next_replica_id = (self._next_replica_id + 1) % len(live)
|
||||
@@ -539,18 +591,23 @@ class StagePool:
|
||||
non_empty_first_output_ts = (
|
||||
self._non_empty_first_output_timestamps_by_request.pop(request_id, None) if request_id else None
|
||||
)
|
||||
num_tokens_out = count_tokens_from_outputs(request_outputs)
|
||||
native_text_metrics = {}
|
||||
if request_id:
|
||||
pop_native_text_metrics = getattr(self.output_processor, "pop_native_text_metrics", None)
|
||||
if callable(pop_native_text_metrics):
|
||||
native_text_metrics = pop_native_text_metrics(request_id)
|
||||
native_generation_tokens = native_text_metrics.get("num_generation_tokens")
|
||||
num_tokens_out = (
|
||||
max(int(native_generation_tokens), 0)
|
||||
if isinstance(native_generation_tokens, int) and not isinstance(native_generation_tokens, bool)
|
||||
else count_tokens_from_outputs(request_outputs)
|
||||
)
|
||||
output_unit_type = self._infer_output_unit_type(request_outputs, token_count=num_tokens_out)
|
||||
output_unit_count = self._count_output_units(
|
||||
request_outputs,
|
||||
unit_type=output_unit_type,
|
||||
fallback_token_count=num_tokens_out,
|
||||
)
|
||||
native_text_metrics = {}
|
||||
if request_id:
|
||||
pop_native_text_metrics = getattr(self.output_processor, "pop_native_text_metrics", None)
|
||||
if callable(pop_native_text_metrics):
|
||||
native_text_metrics = pop_native_text_metrics(request_id)
|
||||
current_audio_frames, current_audio_sample_rate, _ = self._collect_audio_metrics(request_outputs)
|
||||
accumulated_audio_frames = self._audio_frames_by_request.pop(request_id, 0) if request_id else 0
|
||||
accumulated_audio_sample_rate = self._audio_sample_rate_by_request.pop(request_id, 0) if request_id else 0
|
||||
@@ -1029,14 +1086,28 @@ class StagePool:
|
||||
# Refresh the shared output-processor state before yielding to the
|
||||
# stage client so streaming segments are merged against the latest
|
||||
# prompt/token metadata.
|
||||
self.output_processor.add_request(
|
||||
request=request,
|
||||
prompt=prompt_text,
|
||||
parent_req=None,
|
||||
request_index=0,
|
||||
queue=None,
|
||||
)
|
||||
await self._llm_client(replica_id).add_request_async(request)
|
||||
try:
|
||||
self.output_processor.add_request(
|
||||
request=request,
|
||||
prompt=prompt_text,
|
||||
parent_req=None,
|
||||
request_index=0,
|
||||
queue=None,
|
||||
)
|
||||
await self._llm_client(replica_id).add_request_async(request)
|
||||
except Exception:
|
||||
rollback = getattr(self.output_processor, "remove_request", None)
|
||||
if callable(rollback):
|
||||
try:
|
||||
rollback(request_id)
|
||||
except Exception as rollback_error:
|
||||
logger.warning(
|
||||
"[StagePool] Failed to rollback output processor update for req=%s stage-%s: %s",
|
||||
request_id,
|
||||
self.stage_id,
|
||||
rollback_error,
|
||||
)
|
||||
raise
|
||||
return replica_id
|
||||
|
||||
async def _pick_or_select(
|
||||
@@ -1097,6 +1168,8 @@ class StagePool:
|
||||
timeout_s: float = 0.001,
|
||||
) -> EngineCoreOutputs | None:
|
||||
"""Poll raw EngineCore outputs from one LLM replica once."""
|
||||
if not self.is_replica_available(replica_id):
|
||||
return None
|
||||
raw_client = self.clients[replica_id]
|
||||
if raw_client is None:
|
||||
return None
|
||||
@@ -1120,6 +1193,8 @@ class StagePool:
|
||||
|
||||
def poll_diffusion_output(self, replica_id: int) -> Any | None:
|
||||
"""Drain one ready diffusion output from the given replica if present."""
|
||||
if not self.is_replica_available(replica_id):
|
||||
return None
|
||||
raw_client = self.clients[replica_id]
|
||||
if raw_client is None:
|
||||
return None
|
||||
|
||||
@@ -396,7 +396,6 @@ class StageRuntime:
|
||||
replica_metadata.replica_id = replica_id
|
||||
if launch_mode == "remote" and replica_metadata.stage_type != "diffusion":
|
||||
replica_metadata.runtime_cfg = None
|
||||
|
||||
replicas.append(
|
||||
ReplicaInitPlan(
|
||||
replica_id=replica_id,
|
||||
|
||||
@@ -44,6 +44,12 @@ if TYPE_CHECKING:
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.v1.engine import PauseMode
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import (
|
||||
DuplexFence,
|
||||
DuplexSessionLifecycleMessage,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient
|
||||
from vllm_omni.inputs.data import OmniPromptType
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -143,12 +149,16 @@ class AsyncOmni(EngineClient, OmniBase):
|
||||
self._paused: bool = False
|
||||
self._sleeping_tags: set[str] = set()
|
||||
self._level2_sleeping: bool = False
|
||||
self._duplex_request_client: DuplexRequestClient | None = None
|
||||
self.duplex_lifecycle_events: asyncio.Queue[DuplexSessionLifecycleMessage] = asyncio.Queue()
|
||||
self.final_output_task: asyncio.Task | None = None
|
||||
self.event_resolver = AsyncEventResolver(orchestrator=self)
|
||||
self.config_path = self.engine.config_path
|
||||
self.tts_max_instructions_length = kwargs.get("tts_max_instructions_length", None)
|
||||
self.input_processor = self.engine.input_processor
|
||||
self.endpoint_restrictions = self.engine.endpoint_restrictions
|
||||
self.duplex_session_config = self.engine.duplex_session_config
|
||||
self.duplex_serving_adapter_path = self.engine.duplex_serving_adapter_path
|
||||
|
||||
stage_index = self._get_comprehension_stage_index()
|
||||
if stage_index is None:
|
||||
@@ -254,6 +264,191 @@ class AsyncOmni(EngineClient, OmniBase):
|
||||
prefix = "" if not external_request_id else f"{external_request_id}-"
|
||||
return f"{prefix}{uuid:.8}"
|
||||
|
||||
async def open_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
session_mode: str = "duplex",
|
||||
capabilities: dict[str, object] | None = None,
|
||||
session_config: dict[str, object] | None = None,
|
||||
runtime_config: dict[str, object] | None = None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Open an engine-level duplex session when the backend supports it."""
|
||||
return await self._get_duplex_request_client().open(
|
||||
session_id,
|
||||
session_mode=session_mode,
|
||||
capabilities=capabilities,
|
||||
session_config=session_config,
|
||||
runtime_config=runtime_config,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def append_duplex_input_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
mode: str,
|
||||
payload: object,
|
||||
operation_id: str | None = None,
|
||||
final: bool = False,
|
||||
expected_epoch: int | None = None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
collect_outputs: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Append input to an engine-level duplex session."""
|
||||
return await self._get_duplex_request_client().append(
|
||||
session_id,
|
||||
mode=mode,
|
||||
payload=payload,
|
||||
operation_id=operation_id,
|
||||
final=final,
|
||||
expected_epoch=expected_epoch,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
collect_outputs=collect_outputs,
|
||||
)
|
||||
|
||||
async def collect_duplex_data_plane_outputs_async(
|
||||
self,
|
||||
request_id: str,
|
||||
*,
|
||||
response_stage_id: int | None = None,
|
||||
timeout: float | None = 10.0,
|
||||
) -> list[OmniRequestOutput]:
|
||||
"""Collect the next duplex data-plane output batch for a live request."""
|
||||
return await self._get_duplex_request_client().collect_registered_outputs(
|
||||
request_id,
|
||||
response_stage_id=response_stage_id,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def signal_duplex_turn_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
event: str,
|
||||
fence: DuplexFence,
|
||||
next_fence: DuplexFence | None = None,
|
||||
session_config: dict[str, object] | None = None,
|
||||
runtime_config: dict[str, object] | None = None,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Send a turn/control signal to an engine-level duplex session."""
|
||||
return await self._get_duplex_request_client().signal(
|
||||
session_id,
|
||||
event=event,
|
||||
fence=fence,
|
||||
next_fence=next_fence,
|
||||
session_config=session_config,
|
||||
runtime_config=runtime_config,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def close_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
reason: str = "client_close",
|
||||
fence: DuplexFence,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
"""Close an engine-level duplex session."""
|
||||
return await self._get_duplex_request_client().close(
|
||||
session_id,
|
||||
reason=reason,
|
||||
fence=fence,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def touch_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
activity: DuplexLeaseActivity,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
return await self._get_duplex_request_client().touch(
|
||||
session_id,
|
||||
fence=fence,
|
||||
activity=activity,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def resume_duplex_session_async(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
expected_lease_generation: int,
|
||||
timeout: float | None = 10.0,
|
||||
) -> dict[str, object]:
|
||||
return await self._get_duplex_request_client().resume(
|
||||
session_id,
|
||||
fence=fence,
|
||||
expected_lease_generation=expected_lease_generation,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def _get_duplex_request_client(self) -> DuplexRequestClient:
|
||||
from vllm_omni.experimental.fullduplex.request_client import (
|
||||
DuplexRequestClient,
|
||||
DuplexRequestOutputPort,
|
||||
)
|
||||
|
||||
client = getattr(self, "_duplex_request_client", None)
|
||||
if client is None:
|
||||
engine = getattr(self, "engine", None)
|
||||
client = DuplexRequestClient(
|
||||
engine,
|
||||
DuplexRequestOutputPort(
|
||||
request_states=getattr(self, "request_states", {}),
|
||||
num_stages=getattr(engine, "num_stages", 1),
|
||||
log_stats=getattr(self, "log_stats", False),
|
||||
start_output_handler=self._final_output_handler,
|
||||
process_single_result=self._process_single_result,
|
||||
),
|
||||
)
|
||||
self._duplex_request_client = client
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _duplex_data_plane_request_info(result: dict[str, object]) -> tuple[str | None, int | None]:
|
||||
from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient
|
||||
|
||||
return DuplexRequestClient.request_info(result)
|
||||
|
||||
async def _collect_duplex_data_plane_outputs(
|
||||
self,
|
||||
request_id: str,
|
||||
req_state: ClientRequestState,
|
||||
*,
|
||||
response_stage_id: int | None,
|
||||
timeout: float | None,
|
||||
) -> list[OmniRequestOutput]:
|
||||
return await self._get_duplex_request_client().collect_outputs(
|
||||
request_id,
|
||||
req_state,
|
||||
response_stage_id=response_stage_id,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_direct_duplex_data_plane_response(cls, output: object) -> bool:
|
||||
from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient
|
||||
|
||||
return DuplexRequestClient.is_direct_response(output)
|
||||
|
||||
@classmethod
|
||||
def _duplex_multimodal_output(cls, output: object) -> dict[str, object]:
|
||||
from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient
|
||||
|
||||
return DuplexRequestClient.multimodal_output(output)
|
||||
|
||||
# ==================== Generate Method ====================
|
||||
|
||||
async def generate(
|
||||
@@ -693,6 +888,10 @@ class AsyncOmni(EngineClient, OmniBase):
|
||||
await self.event_resolver.resolve(msg)
|
||||
continue
|
||||
|
||||
if getattr(msg, "type", None) == "duplex_session_lifecycle":
|
||||
await self.duplex_lifecycle_events.put(msg)
|
||||
continue
|
||||
|
||||
if isinstance(msg, ErrorMessage) and not msg.fatal:
|
||||
req_state = self.request_states.get(msg.request_id)
|
||||
if req_state is not None:
|
||||
|
||||
@@ -4,7 +4,7 @@ from vllm_omni.metrics import OrchestratorAggregator
|
||||
|
||||
|
||||
class ClientRequestState:
|
||||
"""Tracks the state of an individual request in the orchestrator."""
|
||||
"""Tracks one entrypoint request and its output queue."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -473,6 +473,7 @@ class OmniBase(PDDisaggregationMixin):
|
||||
req_start_ts: dict[str, float],
|
||||
wall_start_ts: float,
|
||||
final_stage_id_for_e2e: int,
|
||||
stage_event_cursor: int = 0,
|
||||
) -> OmniRequestOutput | None:
|
||||
req_id = result.request_id
|
||||
engine_outputs = result.engine_outputs
|
||||
@@ -481,7 +482,7 @@ class OmniBase(PDDisaggregationMixin):
|
||||
|
||||
# Merge AR stage timing from OrchestratorAggregator.stage_events
|
||||
if self._enable_ar_profiler:
|
||||
ar_events = metrics.stage_events.get(str(req_id), [])
|
||||
ar_events = metrics.stage_events.get(str(req_id), [])[stage_event_cursor:]
|
||||
for evt in ar_events:
|
||||
if evt.stage_id != stage_id:
|
||||
stage_durations[f"ar_stage_{evt.stage_id}"] = evt.stage_gen_time_ms / 1000.0
|
||||
@@ -494,7 +495,7 @@ class OmniBase(PDDisaggregationMixin):
|
||||
stage_durations[key] = value
|
||||
|
||||
# Merge per-stage gen times into stage_durations
|
||||
for evt in metrics.stage_events.get(str(req_id), []):
|
||||
for evt in metrics.stage_events.get(str(req_id), [])[stage_event_cursor:]:
|
||||
key = f"stage_{evt.stage_id}_gen_ms"
|
||||
if key not in stage_durations:
|
||||
stage_durations[key] = evt.stage_gen_time_ms
|
||||
@@ -546,7 +547,7 @@ class OmniBase(PDDisaggregationMixin):
|
||||
# Token counters — aggregate across all stages for this request.
|
||||
_prompt_tok = 0
|
||||
_gen_tok = 0
|
||||
for evt in metrics.stage_events.get(rid_key, []):
|
||||
for evt in metrics.stage_events.get(rid_key, [])[stage_event_cursor:]:
|
||||
if evt.stage_id == 0:
|
||||
_prompt_tok += int(evt.num_tokens_in)
|
||||
_gen_tok += int(evt.num_tokens_out)
|
||||
@@ -581,7 +582,7 @@ class OmniBase(PDDisaggregationMixin):
|
||||
response_metrics: dict[str, Any] = {}
|
||||
stage_metrics: dict[str, dict[str, Any]] = {}
|
||||
rid_key = str(req_id)
|
||||
for evt in metrics.stage_events.get(rid_key, []):
|
||||
for evt in metrics.stage_events.get(rid_key, [])[stage_event_cursor:]:
|
||||
if evt.stage_id is None:
|
||||
continue
|
||||
sid = int(evt.stage_id)
|
||||
@@ -604,6 +605,7 @@ class OmniBase(PDDisaggregationMixin):
|
||||
response_metrics["num_tokens_out"] = current_stage_metrics["num_tokens_out"]
|
||||
return OmniRequestOutput(
|
||||
request_id=req_id or "",
|
||||
finished=finished,
|
||||
stage_id=stage_id,
|
||||
final_output_type=output_type,
|
||||
request_output=engine_outputs,
|
||||
@@ -616,7 +618,6 @@ class OmniBase(PDDisaggregationMixin):
|
||||
metrics=response_metrics,
|
||||
stage_durations=stage_durations,
|
||||
peak_memory_mb=peak_memory_mb,
|
||||
finished=finished,
|
||||
)
|
||||
|
||||
def shutdown(self, timeout: float | None = None) -> None:
|
||||
|
||||
@@ -94,6 +94,7 @@ from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
|
||||
from vllm_omni.config.endpoint_policy import shutdown_unsupported_routes
|
||||
from vllm_omni.diffusion.models.interface import ReferenceVideoDecodeSpec
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
from vllm_omni.entrypoints.openai.duplex_capability import should_enable_duplex_endpoint
|
||||
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
|
||||
from vllm_omni.entrypoints.openai.image_api_utils import (
|
||||
SUPPORTED_LAYERED_RESOLUTIONS,
|
||||
@@ -793,6 +794,7 @@ async def omni_init_app_state(
|
||||
model_name=model_name,
|
||||
stage_configs=diffusion_stage_configs,
|
||||
)
|
||||
state.openai_serving_duplex = None
|
||||
state.openai_streaming_speech = None
|
||||
state.openai_streaming_video = None
|
||||
state.openai_serving_realtime_robot = ServingRealtimeRobotOpenPI.create_policy_server(
|
||||
@@ -1112,6 +1114,18 @@ async def omni_init_app_state(
|
||||
if state.openai_serving_chat is not None
|
||||
else None
|
||||
)
|
||||
state.openai_serving_duplex = None
|
||||
if state.openai_serving_chat is not None and should_enable_duplex_endpoint(
|
||||
state.stage_configs,
|
||||
config_path=getattr(args, "stage_configs_path", None) or getattr(args, "deploy_config", None),
|
||||
):
|
||||
from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler
|
||||
|
||||
state.openai_serving_duplex = OmniDuplexSessionHandler(
|
||||
chat_service=state.openai_serving_chat,
|
||||
duplex_session_config=getattr(engine_client, "duplex_session_config", None),
|
||||
serving_runtime_adapter_path=getattr(engine_client, "duplex_serving_adapter_path", None),
|
||||
)
|
||||
state.openai_serving_realtime = OpenAIServingRealtime(
|
||||
engine_client=engine_client,
|
||||
models=state.openai_serving_models,
|
||||
@@ -1612,6 +1626,31 @@ async def streaming_video_output(websocket: WebSocket):
|
||||
@router.websocket("/v1/realtime")
|
||||
async def realtime_websocket(websocket: WebSocket):
|
||||
"""WebSocket endpoint for OpenAI-style realtime interactions."""
|
||||
duplex_handler = getattr(websocket.app.state, "openai_serving_duplex", None)
|
||||
duplex_query = websocket.query_params.get("duplex")
|
||||
use_duplex_realtime = (
|
||||
duplex_handler is not None and isinstance(duplex_query, str) and duplex_query.lower() in {"1", "true", "on"}
|
||||
)
|
||||
if use_duplex_realtime and duplex_handler is not None:
|
||||
await duplex_handler.handle_realtime_session(websocket)
|
||||
return
|
||||
|
||||
engine_client = getattr(websocket.app.state, "engine_client", None)
|
||||
if engine_client is not None and getattr(engine_client, "async_chunk", False):
|
||||
await websocket.accept()
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"error": (
|
||||
"The /v1/realtime API is not supported when async_chunk is enabled on the server. "
|
||||
"Use a stage configuration with async_chunk disabled and restart the server before using "
|
||||
"this endpoint."
|
||||
),
|
||||
"code": "unsupported",
|
||||
}
|
||||
)
|
||||
await websocket.close()
|
||||
return
|
||||
serving = getattr(websocket.app.state, "openai_serving_realtime", None)
|
||||
if serving is None:
|
||||
await websocket.accept()
|
||||
@@ -1639,6 +1678,18 @@ async def realtime_robot_openpi(websocket: WebSocket):
|
||||
await connection.handle_connection()
|
||||
|
||||
|
||||
@router.websocket("/v1/duplex")
|
||||
async def duplex_websocket(websocket: WebSocket):
|
||||
"""WebSocket endpoint for vLLM-Omni duplex session control."""
|
||||
handler = getattr(websocket.app.state, "openai_serving_duplex", None)
|
||||
if handler is None:
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "error", "error": "Duplex API is not available", "code": "unsupported"})
|
||||
await websocket.close()
|
||||
return
|
||||
await handler.handle_session(websocket)
|
||||
|
||||
|
||||
# Health and Model endpoints for diffusion mode
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def should_enable_duplex_endpoint(
|
||||
stage_configs: list | None,
|
||||
*,
|
||||
config_path: str | None = None,
|
||||
) -> bool:
|
||||
"""Enable duplex routes only for deployments that explicitly opt in."""
|
||||
if stage_configs:
|
||||
for stage in stage_configs:
|
||||
session_mode = (
|
||||
stage.get("session_mode") if isinstance(stage, dict) else getattr(stage, "session_mode", None)
|
||||
)
|
||||
if session_mode == "duplex":
|
||||
return True
|
||||
if config_path:
|
||||
try:
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
raw_config = OmegaConf.load(config_path)
|
||||
session_mode = raw_config.get("session_mode") if hasattr(raw_config, "get") else None
|
||||
if session_mode == "duplex":
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to inspect duplex session_mode from %s: %s", config_path, exc)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["should_enable_duplex_endpoint"]
|
||||
@@ -79,7 +79,7 @@ from vllm.outputs import RequestOutput
|
||||
from vllm.reasoning import ReasoningParser
|
||||
from vllm.renderers import BaseRenderer, merge_kwargs
|
||||
from vllm.renderers.inputs import TokPrompt
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.sampling_params import RequestOutputKind, SamplingParams
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tokenizers import TokenizerLike as AnyTokenizer
|
||||
from vllm.tokenizers.mistral import (
|
||||
@@ -187,6 +187,63 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
tp_cls = self.parser_cls.tool_parser_cls if self.parser_cls is not None else None
|
||||
return request.tools and tp_cls is not None and self.enable_auto_tools and request.tool_choice in ["auto", None]
|
||||
|
||||
@staticmethod
|
||||
def _stage_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
if obj is None:
|
||||
return default
|
||||
if hasattr(obj, "get"):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def _has_minicpmo45_stage(self) -> bool:
|
||||
for stage in getattr(self.engine_client, "stage_configs", []) or []:
|
||||
engine_args = self._stage_get(stage, "engine_args")
|
||||
model_arch = self._stage_get(engine_args, "model_arch")
|
||||
if model_arch == "MiniCPMO45OmniForConditionalGeneration":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _fix_minicpmo45_audio_stream_output_kinds(
|
||||
self,
|
||||
sampling_params_list: list[Any],
|
||||
output_modalities: list[str] | tuple[str, ...] | None,
|
||||
) -> list[Any]:
|
||||
"""Keep MiniCPM-o 4.5's thinker final-only during audio streaming.
|
||||
|
||||
Chat ``stream=true`` normally coerces every AR stage to DELTA output.
|
||||
That is correct for token-level text streaming, but MiniCPM-o 4.5's
|
||||
Talker consumes the completed TTS span plus aligned thinker hidden
|
||||
states. If Stage0 is DELTA, llm2tts receives one generated token at a
|
||||
time and repeatedly starts tiny, independent TTS streams. The API then
|
||||
never represents one coherent audio response.
|
||||
|
||||
For this model, stream the audio stage, not the thinker-to-talker
|
||||
boundary.
|
||||
"""
|
||||
if not output_modalities or "audio" not in output_modalities:
|
||||
return sampling_params_list
|
||||
if not self._has_minicpmo45_stage():
|
||||
return sampling_params_list
|
||||
|
||||
stage_configs = getattr(self.engine_client, "stage_configs", []) or []
|
||||
for idx, stage in enumerate(stage_configs):
|
||||
if idx >= len(sampling_params_list):
|
||||
break
|
||||
sp = sampling_params_list[idx]
|
||||
if not hasattr(sp, "output_kind"):
|
||||
continue
|
||||
|
||||
engine_args = self._stage_get(stage, "engine_args")
|
||||
if self._stage_get(engine_args, "model_arch") != "MiniCPMO45OmniForConditionalGeneration":
|
||||
continue
|
||||
|
||||
model_stage = self._stage_get(engine_args, "model_stage")
|
||||
if model_stage == "llm":
|
||||
sp.output_kind = RequestOutputKind.FINAL_ONLY
|
||||
elif model_stage == "tts":
|
||||
sp.output_kind = RequestOutputKind.DELTA
|
||||
return sampling_params_list
|
||||
|
||||
@classmethod
|
||||
def for_diffusion(
|
||||
cls,
|
||||
@@ -605,6 +662,11 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
# to delta to ensure emitted outputs are correctly drained. Otherwise
|
||||
# convert cumulative to Final Only to ensure the output is correct.
|
||||
sampling_params_list = coerce_param_message_types(sampling_params_list, request.stream)
|
||||
if request.stream:
|
||||
sampling_params_list = self._fix_minicpmo45_audio_stream_output_kinds(
|
||||
sampling_params_list,
|
||||
output_modalities,
|
||||
)
|
||||
|
||||
# Apply user-specified overrides to diffusion stage(s) for image generation
|
||||
for idx, sp in enumerate(sampling_params_list):
|
||||
|
||||
@@ -10,12 +10,14 @@ via :class:`VideoStreamPipelineHooks`.
|
||||
Protocol:
|
||||
Client -> Server:
|
||||
{"type": "session.config", ...} # Session config (sent once)
|
||||
{"type": "video.frame", "data": "..."} # base64 JPEG/PNG frame
|
||||
{"type": "video.frame", "data": "...", "frame_id": "...", "pts_ms": 0}
|
||||
{"type": "audio.chunk", "data": "..."} # base64 PCM16 16kHz mono
|
||||
{"type": "video.query", "text": "..."} # Submit query about buffered frames
|
||||
{"type": "video.done"} # End of session
|
||||
|
||||
Server -> Client:
|
||||
{"type": "video.frame.ack", ...} # when frame_id is provided
|
||||
{"type": "video.frames.consumed", ...} # after first engine output
|
||||
{"type": "response.start"}
|
||||
{"type": "response.text.delta", "delta": "..."}
|
||||
{"type": "response.text.done", "text": "..."}
|
||||
@@ -215,6 +217,7 @@ class OmniStreamingVideoHandler:
|
||||
return
|
||||
|
||||
frame_buffer: list[str] = [] # base64-encoded JPEG frames
|
||||
frame_metadata: list[dict[str, Any]] = []
|
||||
# Per-frame PIL cache + uuid for mm_hash reuse. Aligned with frame_buffer by index.
|
||||
frame_pil_cache: dict[str, tuple[Any, str] | object] = {} # b64 -> (PIL.Image, uuid) or _BAD_FRAME
|
||||
frame_filter = (
|
||||
@@ -259,6 +262,8 @@ class OmniStreamingVideoHandler:
|
||||
if msg_type.startswith("_internal."):
|
||||
await self._send_error(websocket, f"Unknown type: {msg_type}")
|
||||
continue
|
||||
if msg_type == "video.frame":
|
||||
msg["_receiver_received_ts_ms"] = _time.monotonic() * 1000
|
||||
|
||||
await msg_queue.put(msg)
|
||||
if msg.get("type") == "video.done":
|
||||
@@ -308,6 +313,7 @@ class OmniStreamingVideoHandler:
|
||||
active_request_id = request_id
|
||||
interrupt_event.clear()
|
||||
query_frames = list(frame_buffer)
|
||||
query_frame_metadata = list(frame_metadata)
|
||||
query_audio_buffer = bytearray(audio_buffer)
|
||||
audio_buffer.clear()
|
||||
query_prewarmed_frames = dict(frame_pil_cache)
|
||||
@@ -315,6 +321,9 @@ class OmniStreamingVideoHandler:
|
||||
async def _run_query() -> None:
|
||||
nonlocal active_request_id, prev_request_id
|
||||
try:
|
||||
process_kwargs: dict[str, Any] = {}
|
||||
if any(metadata.get("frame_id") for metadata in query_frame_metadata):
|
||||
process_kwargs["frame_metadata"] = query_frame_metadata
|
||||
await self._process_query(
|
||||
websocket,
|
||||
config,
|
||||
@@ -325,6 +334,7 @@ class OmniStreamingVideoHandler:
|
||||
request_id,
|
||||
interrupt_event,
|
||||
query_prewarmed_frames,
|
||||
**process_kwargs,
|
||||
)
|
||||
finally:
|
||||
if active_request_id == request_id:
|
||||
@@ -349,7 +359,11 @@ class OmniStreamingVideoHandler:
|
||||
frame_data = msg.get("b64", "")
|
||||
removed = frame_data in frame_buffer
|
||||
if removed:
|
||||
frame_buffer[:] = [f for f in frame_buffer if f != frame_data]
|
||||
retained_indices = [
|
||||
index for index, frame in enumerate(frame_buffer) if frame != frame_data
|
||||
]
|
||||
frame_buffer[:] = [frame_buffer[index] for index in retained_indices]
|
||||
frame_metadata[:] = [frame_metadata[index] for index in retained_indices]
|
||||
if frame_pil_cache.get(frame_data) is _BAD_FRAME:
|
||||
frame_pil_cache.pop(frame_data, None)
|
||||
if removed:
|
||||
@@ -370,16 +384,43 @@ class OmniStreamingVideoHandler:
|
||||
if frame_filter is not None:
|
||||
try:
|
||||
if not frame_filter.should_retain(raw_bytes):
|
||||
await self._send_frame_ack(
|
||||
websocket,
|
||||
msg,
|
||||
accepted=False,
|
||||
buffered_frames=len(frame_buffer),
|
||||
reason="filtered",
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
await self._send_error(websocket, "Invalid image data")
|
||||
continue
|
||||
max_buf = config.max_frames
|
||||
dropped_frame_id: str | None = None
|
||||
if len(frame_buffer) >= max_buf:
|
||||
dropped = frame_buffer.pop(0)
|
||||
dropped_metadata = frame_metadata.pop(0)
|
||||
dropped_frame_id = dropped_metadata.get("frame_id")
|
||||
frame_pil_cache.pop(dropped, None)
|
||||
frame_buffer.append(frame_data)
|
||||
frame_metadata.append(
|
||||
{
|
||||
"frame_id": msg.get("frame_id"),
|
||||
"pts_ms": msg.get("pts_ms"),
|
||||
"source_pts_ms": msg.get("source_pts_ms"),
|
||||
"quality_profile": msg.get("quality_profile"),
|
||||
"capture_ts_ms": msg.get("capture_ts_ms"),
|
||||
"receiver_received_ts_ms": msg.get("_receiver_received_ts_ms"),
|
||||
}
|
||||
)
|
||||
self.on_frame_buffered(raw_bytes, frame_data, message_history, config)
|
||||
await self._send_frame_ack(
|
||||
websocket,
|
||||
msg,
|
||||
accepted=True,
|
||||
buffered_frames=len(frame_buffer),
|
||||
dropped_frame_id=dropped_frame_id,
|
||||
)
|
||||
# Prewarm: decode PIL off the event loop so query-time chat_template
|
||||
# can skip base64+Image.open. uuid=md5 lets mm_cache dedupe identical frames.
|
||||
if frame_data not in frame_pil_cache:
|
||||
@@ -537,6 +578,7 @@ class OmniStreamingVideoHandler:
|
||||
request_id: str,
|
||||
interrupt_event: asyncio.Event,
|
||||
prewarmed_frames: dict[str, tuple[Any, str]],
|
||||
frame_metadata: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Build prompt, run inference, stream text + audio response."""
|
||||
|
||||
@@ -544,6 +586,9 @@ class OmniStreamingVideoHandler:
|
||||
await self._send_error(websocket, "Streaming video requires an engine client")
|
||||
return
|
||||
|
||||
engine_kwargs: dict[str, Any] = {}
|
||||
if frame_metadata:
|
||||
engine_kwargs["frame_metadata"] = frame_metadata
|
||||
await self._process_query_engine(
|
||||
websocket,
|
||||
config,
|
||||
@@ -554,6 +599,7 @@ class OmniStreamingVideoHandler:
|
||||
request_id,
|
||||
interrupt_event,
|
||||
prewarmed_frames,
|
||||
**engine_kwargs,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -571,6 +617,7 @@ class OmniStreamingVideoHandler:
|
||||
request_id: str,
|
||||
interrupt_event: asyncio.Event,
|
||||
prewarmed_frames: dict[str, tuple[Any, str]],
|
||||
frame_metadata: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Direct engine_client.generate() path for async_chunk audio."""
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
@@ -613,6 +660,9 @@ class OmniStreamingVideoHandler:
|
||||
except Exception as e:
|
||||
await self._send_error(websocket, f"Preprocess failed: {e}")
|
||||
return
|
||||
decoded_ready_ts_ms = _time.monotonic() * 1000
|
||||
selected_metadata = self._sample_frame_metadata(frame_metadata or [], config.num_frames)
|
||||
model_selected_ts_ms = _time.monotonic() * 1000
|
||||
|
||||
await websocket.send_json({"type": "response.start"})
|
||||
text_parts: list[str] = []
|
||||
@@ -623,6 +673,7 @@ class OmniStreamingVideoHandler:
|
||||
audio_chunks_drained = 0
|
||||
previous_text = ""
|
||||
interrupted = False
|
||||
frames_consumed_sent = False
|
||||
t_start = _time.monotonic()
|
||||
t_first_text = None
|
||||
t_first_audio = None
|
||||
@@ -652,6 +703,33 @@ class OmniStreamingVideoHandler:
|
||||
if not isinstance(output, OmniRequestOutput):
|
||||
continue
|
||||
|
||||
if not frames_consumed_sent and frame_metadata:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "video.frames.consumed",
|
||||
"request_id": request_id,
|
||||
"model_selected_ts_ms": model_selected_ts_ms,
|
||||
"frame_ids": [
|
||||
metadata["frame_id"]
|
||||
for metadata in selected_metadata
|
||||
if isinstance(metadata.get("frame_id"), str)
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
"frame_id": metadata.get("frame_id"),
|
||||
"pts_ms": metadata.get("pts_ms"),
|
||||
"source_pts_ms": metadata.get("source_pts_ms"),
|
||||
"quality_profile": metadata.get("quality_profile"),
|
||||
"receiver_received_ts_ms": metadata.get("receiver_received_ts_ms"),
|
||||
"decoded_ready_ts_ms": decoded_ready_ts_ms,
|
||||
}
|
||||
for metadata in selected_metadata
|
||||
],
|
||||
"latest_pts_ms": selected_metadata[-1].get("pts_ms") if selected_metadata else None,
|
||||
}
|
||||
)
|
||||
frames_consumed_sent = True
|
||||
|
||||
out_type = getattr(output, "final_output_type", "text")
|
||||
|
||||
if out_type == "audio":
|
||||
@@ -971,3 +1049,42 @@ class OmniStreamingVideoHandler:
|
||||
await websocket.send_json({"type": "error", "message": message})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _sample_frame_metadata(
|
||||
frame_metadata: list[dict[str, Any]],
|
||||
num_frames: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
if len(frame_metadata) <= num_frames:
|
||||
return list(frame_metadata)
|
||||
stride = max(1, len(frame_metadata) // num_frames)
|
||||
indices = [index * stride for index in range(num_frames - 1)] + [len(frame_metadata) - 1]
|
||||
return [frame_metadata[index] for index in indices]
|
||||
|
||||
@staticmethod
|
||||
async def _send_frame_ack(
|
||||
websocket: WebSocket,
|
||||
message: Mapping[str, Any],
|
||||
*,
|
||||
accepted: bool,
|
||||
buffered_frames: int,
|
||||
reason: str | None = None,
|
||||
dropped_frame_id: str | None = None,
|
||||
) -> None:
|
||||
frame_id = message.get("frame_id")
|
||||
if not isinstance(frame_id, str) or not frame_id:
|
||||
return
|
||||
ack: dict[str, Any] = {
|
||||
"type": "video.frame.ack",
|
||||
"frame_id": frame_id,
|
||||
"pts_ms": message.get("pts_ms"),
|
||||
"capture_ts_ms": message.get("capture_ts_ms"),
|
||||
"accepted": accepted,
|
||||
"buffered_frames": buffered_frames,
|
||||
"server_receive_ts_ms": message.get("_receiver_received_ts_ms", _time.monotonic() * 1000),
|
||||
}
|
||||
if reason is not None:
|
||||
ack["reason"] = reason
|
||||
if dropped_frame_id is not None:
|
||||
ack["dropped_frame_id"] = dropped_frame_id
|
||||
await websocket.send_json(ack)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,80 +1,28 @@
|
||||
# Full-duplex interaction framework
|
||||
# Experimental Full-Duplex Runtime
|
||||
|
||||
A model-agnostic framework for real-time, full-duplex (streaming-in / streaming-out)
|
||||
model interaction, plus the JoyVL implementation built on it.
|
||||
This package contains two experimental integrations:
|
||||
|
||||
To **run the JoyVL model**, see the recipe:
|
||||
- the existing JoyVL framework and example integration;
|
||||
- the MiniCPM-o 4.5 native audio path used by `/v1/duplex` and
|
||||
`/v1/realtime?duplex=1`.
|
||||
|
||||
For the MiniCPM active runtime path, lifecycle invariants, capability boundary,
|
||||
Realtime response contract, and validation scope, see [`DESIGN.md`](DESIGN.md).
|
||||
|
||||
To run JoyVL, see
|
||||
[`recipes/JD/JoyAI-VL-Interaction.md`](../../../recipes/JD/JoyAI-VL-Interaction.md).
|
||||
This README covers the framework itself and how to add a new model.
|
||||
|
||||
## Layout
|
||||
## Package boundaries
|
||||
|
||||
```
|
||||
vllm_omni/experimental/fullduplex/
|
||||
core/ generic full-duplex framework (model-agnostic): DuplexRuntime (event
|
||||
loop + epoch barge-in), DuplexSession, DuplexAdapter (ABC), protocol
|
||||
joyvl/ JoyVL implementation (model-specific):
|
||||
adapter.py JoyVLDuplexAdapter (implements core.DuplexAdapter)
|
||||
decision/ policy + output_parser + prompts (speak/silence/delegate)
|
||||
memory/ InteractionBrain — 3-tier summary memory (async)
|
||||
serving/ OpenAI-compatible HTTP orchestrator
|
||||
bridges/ model backend + delegation
|
||||
```text
|
||||
core/ existing JoyVL framework and experimental compatibility exports
|
||||
engine/ AsyncOmni/orchestrator scheduler data-plane adapter
|
||||
openai/ WebSocket transport, Realtime projection, and audio codecs
|
||||
minicpmo45/ MiniCPM input framing, policy, compatibility, and Stage0 state
|
||||
joyvl/ JoyVL model-specific integration
|
||||
```
|
||||
|
||||
`core/` is the only part shared across models; data planes differ by model and are
|
||||
intentionally not shared.
|
||||
|
||||
## Scope
|
||||
|
||||
The runnable serving path is `joyvl/serving/` driving `decision/` + `memory/` directly.
|
||||
`joyvl/adapter.py` + `core/` are a **demonstration** of how a model plugs into the
|
||||
generic full-duplex framework (exercised by `tests/fullduplex/`), not the serving path
|
||||
the HTTP orchestrator currently uses — a fused-audio model (e.g. MiniCPM-o) is the case
|
||||
`core/` is built for.
|
||||
|
||||
## Adding a full-duplex model
|
||||
|
||||
The seam is `core.DuplexAdapter`. `core/` owns the session lifecycle, epoch-based
|
||||
barge-in, playback cursor, and the event protocol — you implement only model policy.
|
||||
|
||||
1. Create a sibling package `vllm_omni/experimental/fullduplex/<model>/` next to `joyvl/`; keep
|
||||
model-specific code there and do not touch `core/`.
|
||||
2. Implement one `DuplexAdapter` (three required methods; the rest have defaults):
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
from vllm_omni.experimental.fullduplex.core.adapter import DuplexAdapter, DuplexCapability, OutputChunk
|
||||
from vllm_omni.experimental.fullduplex.core.session import DuplexSession
|
||||
|
||||
class MyModelAdapter(DuplexAdapter):
|
||||
def capabilities(self) -> DuplexCapability:
|
||||
return DuplexCapability(
|
||||
input_modalities=frozenset({"audio", "text"}),
|
||||
output_modalities=frozenset({"audio", "text"}),
|
||||
proactive=True, # speak without being asked?
|
||||
)
|
||||
|
||||
async def on_input(self, session: DuplexSession, modality: str, data) -> None:
|
||||
... # buffer/route an incoming chunk
|
||||
|
||||
async def respond(self, session: DuplexSession) -> AsyncIterator[OutputChunk]:
|
||||
async for piece in self._model_stream(session):
|
||||
yield OutputChunk(modality="audio", data=piece) # runtime drops stale
|
||||
# chunks after a barge-in
|
||||
|
||||
# optional: should_respond / on_barge_in / on_playback_ack
|
||||
```
|
||||
|
||||
3. Run it through the shared runtime — no new control-plane code:
|
||||
|
||||
```python
|
||||
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime
|
||||
from vllm_omni.experimental.fullduplex.core.session import DuplexSession, DuplexSessionConfig
|
||||
|
||||
rt = DuplexRuntime(DuplexSession("sid", DuplexSessionConfig()), MyModelAdapter())
|
||||
await rt.run(input_events, emit)
|
||||
```
|
||||
|
||||
`joyvl/adapter.py` is the worked demonstration (currently exercised by tests, not the
|
||||
HTTP serving path). Promote a helper from a model package up into `core/` only once a
|
||||
second model actually needs it.
|
||||
MiniCPM does not run through the removed experimental `core.DuplexRuntime`
|
||||
facade. Its active path uses the `openai` session controller, the experimental
|
||||
engine contracts, the standard scheduler/model runners, and an injected
|
||||
MiniCPM-specific runtime extension from `minicpmo45/runtime.py`.
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Reusable Realtime WebSocket, PCM, and event helpers for MiniCPM-o demos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
except ImportError as exc: # pragma: no cover - example dependency
|
||||
raise SystemExit("Install websockets first: pip install websockets") from exc
|
||||
|
||||
PCM16_SAMPLE_RATE = 16_000
|
||||
PCM16_BYTES_PER_SAMPLE = 2
|
||||
|
||||
|
||||
def _rounded_ms(value: float) -> float:
|
||||
return round(float(value), 3)
|
||||
|
||||
|
||||
def _interval_summary(values: list[float]) -> dict[str, float | int]:
|
||||
clean = sorted(_rounded_ms(value) for value in values if math.isfinite(value) and value >= 0)
|
||||
if not clean:
|
||||
return {"count": 0, "mean": 0.0, "p50": 0.0, "p95": 0.0, "max": 0.0}
|
||||
|
||||
def nearest_rank(percentile: float) -> float:
|
||||
index = max(0, math.ceil(percentile * len(clean)) - 1)
|
||||
return clean[min(index, len(clean) - 1)]
|
||||
|
||||
return {
|
||||
"count": len(clean),
|
||||
"mean": _rounded_ms(sum(clean) / len(clean)),
|
||||
"p50": nearest_rank(0.50),
|
||||
"p95": nearest_rank(0.95),
|
||||
"max": clean[-1],
|
||||
}
|
||||
|
||||
|
||||
def _event_stage_metrics(event: dict[str, object]) -> dict[str, object] | None:
|
||||
candidates: list[object] = [event.get("vllm_omni")]
|
||||
metadata = event.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
candidates.extend((metadata, metadata.get("vllm_omni")))
|
||||
response = event.get("response")
|
||||
if isinstance(response, dict):
|
||||
response_metadata = response.get("metadata")
|
||||
if isinstance(response_metadata, dict):
|
||||
candidates.extend((response_metadata, response_metadata.get("vllm_omni")))
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
stage_metrics = candidate.get("stage_metrics")
|
||||
if isinstance(stage_metrics, dict):
|
||||
return stage_metrics
|
||||
return None
|
||||
|
||||
|
||||
def build_realtime_url(
|
||||
url: str,
|
||||
model: str,
|
||||
*,
|
||||
autostart: bool | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Add the explicit native-duplex query parameters to a Realtime URL."""
|
||||
parts = urlsplit(url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query.setdefault("duplex", "1")
|
||||
query.setdefault("model", model)
|
||||
query.setdefault("minicpmo45_native_duplex", "1")
|
||||
if autostart is not None:
|
||||
query.setdefault("autostart", "1" if autostart else "0")
|
||||
if session_id:
|
||||
query.setdefault("session_id", session_id)
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
def read_pcm16_wav(path: Path) -> bytes:
|
||||
"""Read a mono, uncompressed, 16 kHz PCM16 WAV file."""
|
||||
with wave.open(str(path), "rb") as wav_file:
|
||||
if wav_file.getnchannels() != 1:
|
||||
raise ValueError("input WAV must be mono")
|
||||
if wav_file.getsampwidth() != PCM16_BYTES_PER_SAMPLE:
|
||||
raise ValueError("input WAV must be 16-bit PCM")
|
||||
if wav_file.getframerate() != PCM16_SAMPLE_RATE:
|
||||
raise ValueError("input WAV must be 16 kHz")
|
||||
if wav_file.getcomptype() != "NONE":
|
||||
raise ValueError("input WAV must be uncompressed PCM")
|
||||
return wav_file.readframes(wav_file.getnframes())
|
||||
|
||||
|
||||
def write_pcm16_wav(path: Path, pcm16: bytes, *, sample_rate_hz: int) -> None:
|
||||
"""Write mono PCM16 bytes as a WAV artifact."""
|
||||
with wave.open(str(path), "wb") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(PCM16_BYTES_PER_SAMPLE)
|
||||
wav_file.setframerate(sample_rate_hz)
|
||||
wav_file.writeframes(pcm16)
|
||||
|
||||
|
||||
async def wait_for(
|
||||
predicate: Callable[[], bool],
|
||||
*,
|
||||
timeout_s: float,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""Wait for a collector predicate without coupling to a scenario runner."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
raise TimeoutError(f"Timed out waiting for {label}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealtimeEventCollector:
|
||||
"""Collect server events and decode response audio by response identity."""
|
||||
|
||||
events: list[dict[str, object]] = field(default_factory=list)
|
||||
event_received_at_s: list[float] = field(default_factory=list)
|
||||
response_audio: dict[str, list[bytes]] = field(default_factory=dict)
|
||||
response_ids: list[str] = field(default_factory=list)
|
||||
output_sample_rate_hz: int = 24_000
|
||||
|
||||
@staticmethod
|
||||
def response_id(event: dict[str, object]) -> str | None:
|
||||
response_id = event.get("response_id")
|
||||
if isinstance(response_id, str):
|
||||
return response_id
|
||||
response = event.get("response")
|
||||
if isinstance(response, dict):
|
||||
response_id = response.get("id")
|
||||
if isinstance(response_id, str):
|
||||
return response_id
|
||||
return None
|
||||
|
||||
def add(self, event: dict[str, object], *, received_at_s: float | None = None) -> None:
|
||||
received_at = time.monotonic() if received_at_s is None else float(received_at_s)
|
||||
stored_event = dict(event)
|
||||
stored_event.setdefault("_client_received_at_s", received_at)
|
||||
self.events.append(stored_event)
|
||||
self.event_received_at_s.append(received_at)
|
||||
response_id = self.response_id(stored_event)
|
||||
event_type = stored_event.get("type")
|
||||
if event_type == "response.created" and response_id and response_id not in self.response_ids:
|
||||
self.response_ids.append(response_id)
|
||||
if event_type == "response.audio.delta":
|
||||
delta = stored_event.get("delta") or stored_event.get("audio")
|
||||
if isinstance(delta, str) and response_id:
|
||||
try:
|
||||
self.response_audio.setdefault(response_id, []).append(base64.b64decode(delta))
|
||||
except ValueError:
|
||||
pass
|
||||
sample_rate_hz = stored_event.get("sample_rate_hz")
|
||||
if isinstance(sample_rate_hz, int) and sample_rate_hz > 0:
|
||||
self.output_sample_rate_hz = sample_rate_hz
|
||||
|
||||
def count(self, event_type: str) -> int:
|
||||
return sum(event.get("type") == event_type for event in self.events)
|
||||
|
||||
def audio_bytes(self, response_id: str | None = None) -> bytes:
|
||||
if response_id is not None:
|
||||
return b"".join(self.response_audio.get(response_id, ()))
|
||||
return b"".join(
|
||||
chunk for response_id in self.response_ids for chunk in self.response_audio.get(response_id, ())
|
||||
)
|
||||
|
||||
def errors(self) -> list[dict[str, object]]:
|
||||
return [event for event in self.events if event.get("type") == "error"]
|
||||
|
||||
def first_received_at(
|
||||
self,
|
||||
*event_types: str,
|
||||
after_s: float = 0.0,
|
||||
) -> float | None:
|
||||
for event, received_at_s in zip(self.events, self.event_received_at_s, strict=True):
|
||||
if received_at_s >= after_s and event.get("type") in event_types:
|
||||
return received_at_s
|
||||
return None
|
||||
|
||||
def last_received_at(self, event_type: str) -> float | None:
|
||||
for event, received_at_s in zip(
|
||||
reversed(self.events),
|
||||
reversed(self.event_received_at_s),
|
||||
strict=True,
|
||||
):
|
||||
if event.get("type") == event_type:
|
||||
return received_at_s
|
||||
return None
|
||||
|
||||
def timing_summary(
|
||||
self,
|
||||
*,
|
||||
after_s: float,
|
||||
input_committed_at_s: float | None = None,
|
||||
response_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Summarize engine token metrics and client-observed audio cadence."""
|
||||
stage0_metrics: dict[str, object] | None = None
|
||||
response_created_at_s: float | None = None
|
||||
audio_received_at_s: list[float] = []
|
||||
cumulative_audio_ms: list[float] = []
|
||||
for event, received_at_s in zip(self.events, self.event_received_at_s, strict=True):
|
||||
if received_at_s < after_s:
|
||||
continue
|
||||
event_response_id = self.response_id(event)
|
||||
if response_id is not None and event_response_id != response_id:
|
||||
continue
|
||||
if event.get("type") == "response.created" and response_created_at_s is None:
|
||||
response_created_at_s = received_at_s
|
||||
|
||||
stage_metrics = _event_stage_metrics(event)
|
||||
stage0 = stage_metrics.get("0") if isinstance(stage_metrics, dict) else None
|
||||
if isinstance(stage0, dict):
|
||||
stage0_metrics = stage0
|
||||
|
||||
if event.get("type") != "response.audio.delta" or (
|
||||
response_id is not None and event_response_id != response_id
|
||||
):
|
||||
continue
|
||||
delta = event.get("delta") or event.get("audio")
|
||||
if not isinstance(delta, str) or not delta:
|
||||
continue
|
||||
audio_received_at_s.append(received_at_s)
|
||||
metadata = event.get("metadata")
|
||||
duration_ms = metadata.get("audio_duration_ms") if isinstance(metadata, dict) else None
|
||||
if isinstance(duration_ms, int | float) and math.isfinite(float(duration_ms)):
|
||||
cumulative_audio_ms.append(max(0.0, float(duration_ms)))
|
||||
|
||||
result: dict[str, object] = {}
|
||||
if stage0_metrics is not None:
|
||||
raw_itls = stage0_metrics.get("vllm_itls_ms")
|
||||
itls = (
|
||||
[float(value) for value in raw_itls if isinstance(value, int | float)]
|
||||
if isinstance(raw_itls, list)
|
||||
else []
|
||||
)
|
||||
result["stage0_tokens"] = {
|
||||
"source": "engine_stage_metrics",
|
||||
"output_token_count": int(stage0_metrics.get("num_tokens_out") or 0),
|
||||
"ttft_ms": float(stage0_metrics.get("vllm_ttft_ms") or 0.0),
|
||||
"tpot_ms": float(stage0_metrics.get("vllm_tpot_ms") or 0.0),
|
||||
"inter_token_interval_ms": _interval_summary(itls),
|
||||
}
|
||||
|
||||
if audio_received_at_s:
|
||||
intervals_ms = [
|
||||
(current - previous) * 1000.0 for previous, current in zip(audio_received_at_s, audio_received_at_s[1:])
|
||||
]
|
||||
chunk_durations_ms: list[float] = []
|
||||
previous_duration_ms = 0.0
|
||||
for duration_ms in cumulative_audio_ms:
|
||||
chunk_durations_ms.append(
|
||||
duration_ms - previous_duration_ms if duration_ms >= previous_duration_ms else duration_ms
|
||||
)
|
||||
previous_duration_ms = duration_ms
|
||||
interval_summary = _interval_summary(intervals_ms)
|
||||
result["audio_output"] = {
|
||||
"source": "client_monotonic_receive",
|
||||
"chunk_count": len(audio_received_at_s),
|
||||
"response_created_to_first_audio_ms": (
|
||||
_rounded_ms((audio_received_at_s[0] - response_created_at_s) * 1000.0)
|
||||
if response_created_at_s is not None
|
||||
else None
|
||||
),
|
||||
"commit_to_first_audio_ms": (
|
||||
_rounded_ms((audio_received_at_s[0] - input_committed_at_s) * 1000.0)
|
||||
if input_committed_at_s is not None
|
||||
else None
|
||||
),
|
||||
"inter_chunk_interval_ms": interval_summary,
|
||||
"chunk_duration_ms": _interval_summary(chunk_durations_ms),
|
||||
"max_chunk_gap_ms": interval_summary["max"],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
class RealtimeDuplexClient:
|
||||
"""Small async client used by the user demo and reusable smoke probes."""
|
||||
|
||||
def __init__(self, url: str, *, max_size: int = 64 * 1024 * 1024) -> None:
|
||||
self.url = url
|
||||
self.max_size = max_size
|
||||
self.events = RealtimeEventCollector()
|
||||
self._ws: Any = None
|
||||
self._reader_task: asyncio.Task[None] | None = None
|
||||
|
||||
async def __aenter__(self) -> RealtimeDuplexClient:
|
||||
self._ws = await websockets.connect(self.url, max_size=self.max_size)
|
||||
self._reader_task = asyncio.create_task(self._read_events())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||
if self._ws is not None:
|
||||
await self._ws.close()
|
||||
if self._reader_task is not None:
|
||||
self._reader_task.cancel()
|
||||
try:
|
||||
await self._reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _read_events(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await self._ws.recv()
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
event = json.loads(raw)
|
||||
if isinstance(event, dict):
|
||||
self.events.add(event)
|
||||
except ConnectionClosed:
|
||||
return
|
||||
|
||||
async def send(self, event: dict[str, object]) -> None:
|
||||
await self._ws.send(json.dumps(event))
|
||||
|
||||
async def configure(
|
||||
self,
|
||||
model: str,
|
||||
*,
|
||||
output_audio_format: str = "pcm16",
|
||||
ref_audio: str | None = None,
|
||||
session_id: str | None = None,
|
||||
timeout_s: float = 20.0,
|
||||
) -> None:
|
||||
session: dict[str, object] = {
|
||||
"model": model,
|
||||
"modalities": ["audio", "text"],
|
||||
"input_audio_format": "pcm16",
|
||||
"output_audio_format": output_audio_format,
|
||||
"turn_detection": None,
|
||||
"overlap_policy": "listen_only",
|
||||
"playback_commit_policy": "ack_only",
|
||||
"extra_body": {
|
||||
"auto_response": True,
|
||||
"minicpmo45_native_duplex": True,
|
||||
"force_listen_count": 0,
|
||||
},
|
||||
}
|
||||
if ref_audio is not None:
|
||||
session["ref_audio"] = ref_audio
|
||||
if session_id:
|
||||
session["session_id"] = session_id
|
||||
await self.send({"type": "session.update", "session": session})
|
||||
await wait_for(
|
||||
lambda: self.events.count("session.created") > 0,
|
||||
timeout_s=timeout_s,
|
||||
label="session.created",
|
||||
)
|
||||
|
||||
async def stream_pcm16(
|
||||
self,
|
||||
pcm16: bytes,
|
||||
*,
|
||||
chunk_ms: int = 200,
|
||||
realtime: bool = True,
|
||||
) -> None:
|
||||
chunk_bytes = max(
|
||||
PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_ms // 1000,
|
||||
PCM16_BYTES_PER_SAMPLE,
|
||||
)
|
||||
audio_end_ms = 0
|
||||
for offset in range(0, len(pcm16), chunk_bytes):
|
||||
chunk = pcm16[offset : offset + chunk_bytes]
|
||||
duration_ms = len(chunk) * 1000 // (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE)
|
||||
audio_end_ms += duration_ms
|
||||
await self.send(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(chunk).decode("ascii"),
|
||||
"input_audio_format": "pcm16",
|
||||
"sample_rate_hz": PCM16_SAMPLE_RATE,
|
||||
"duration_ms": duration_ms,
|
||||
"audio_end_ms": audio_end_ms,
|
||||
}
|
||||
)
|
||||
if realtime:
|
||||
await asyncio.sleep(duration_ms / 1000)
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self.send({"type": "input_audio_buffer.commit", "final": True})
|
||||
|
||||
async def acknowledge_playback(self) -> None:
|
||||
for response_id in self.events.response_ids:
|
||||
pcm16 = self.events.audio_bytes(response_id)
|
||||
if not pcm16:
|
||||
continue
|
||||
played_ms = len(pcm16) * 1000 // (self.events.output_sample_rate_hz * PCM16_BYTES_PER_SAMPLE)
|
||||
await self.send(
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": f"item_{response_id}",
|
||||
"played_ms": played_ms,
|
||||
"committed_ms": played_ms,
|
||||
}
|
||||
)
|
||||
|
||||
async def close_session(self, *, timeout_s: float = 20.0) -> None:
|
||||
await self.send({"type": "session.close"})
|
||||
await wait_for(
|
||||
lambda: self.events.count("session.closed") > 0,
|
||||
timeout_s=timeout_s,
|
||||
label="session.closed",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Engine-side helpers for the experimental full-duplex runtime."""
|
||||
@@ -0,0 +1,276 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Model-neutral contracts for the experimental duplex engine plugin.
|
||||
|
||||
This module contains only immutable data transfer objects and narrow protocols.
|
||||
Duplex control algorithms, session implementations, model policy, and Realtime
|
||||
serving remain in sibling experimental modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol
|
||||
|
||||
from vllm_omni.engine.messages import EngineQueueMessage
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
|
||||
class SessionMode(str, Enum):
|
||||
TURN = "turn"
|
||||
DUPLEX = "duplex"
|
||||
|
||||
|
||||
class DuplexInputMode(str, Enum):
|
||||
APPEND_TOKENS = "append_tokens"
|
||||
APPEND_AUDIO_CHUNK = "append_audio_chunk"
|
||||
REPLACE_LATEST_CHUNK = "replace_latest_chunk"
|
||||
REENCODE_CONTEXT = "reencode_context"
|
||||
ROLLBACK_TO_CHECKPOINT = "rollback_to_checkpoint"
|
||||
TURN_COMMIT_ONLY = "turn_commit_only"
|
||||
|
||||
|
||||
class DuplexOutputAction(str, Enum):
|
||||
DIRECT_RESPONSE = "direct_response"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplexRuntimeCapabilities:
|
||||
input_modes: set[DuplexInputMode] = field(default_factory=lambda: {DuplexInputMode.TURN_COMMIT_ONLY})
|
||||
implementation_level: str = "serving_session_adapter"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexAppendPlan:
|
||||
prompt: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexOutputDecision:
|
||||
action: DuplexOutputAction
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
final_output_type: str = "text"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
|
||||
|
||||
|
||||
class DuplexRuntimeExtension(Protocol):
|
||||
"""Pure model policy invoked by the experimental duplex control plane."""
|
||||
|
||||
def configure_sampling_params(
|
||||
self,
|
||||
*,
|
||||
runtime_config: dict[str, Any],
|
||||
defaults: tuple[object, ...],
|
||||
) -> tuple[object, ...]: ...
|
||||
|
||||
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: ...
|
||||
|
||||
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: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexRequestIdentity:
|
||||
session_id: str
|
||||
fence: DuplexFence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexStageRequestContext:
|
||||
request_id: str
|
||||
session_id: str
|
||||
fence: DuplexFence
|
||||
stage_id: int
|
||||
final_stage_id: int
|
||||
config_generation: int
|
||||
sampling_params: tuple[object, ...]
|
||||
session_config: Mapping[str, Any] = field(default_factory=dict)
|
||||
runtime_config: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "sampling_params", tuple(self.sampling_params))
|
||||
object.__setattr__(self, "session_config", MappingProxyType(dict(self.session_config)))
|
||||
object.__setattr__(self, "runtime_config", MappingProxyType(dict(self.runtime_config)))
|
||||
|
||||
@property
|
||||
def stage_sampling_params(self) -> object:
|
||||
return self.sampling_params[self.stage_id]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexStageSubmission:
|
||||
context: DuplexStageRequestContext
|
||||
prompt: Mapping[str, Any]
|
||||
already_submitted: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "prompt", MappingProxyType(dict(self.prompt)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexStageSubmissionResult:
|
||||
request_id: str
|
||||
stage_id: int
|
||||
replica_id: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexOutputContext:
|
||||
identity: DuplexRequestIdentity
|
||||
final_stage_id: int
|
||||
segment_finished: bool
|
||||
segment_token_ids: tuple[int, ...] = ()
|
||||
segment_output_metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "segment_token_ids", tuple(self.segment_token_ids))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"segment_output_metadata",
|
||||
MappingProxyType(dict(self.segment_output_metadata)),
|
||||
)
|
||||
|
||||
|
||||
class DuplexStagePort(Protocol):
|
||||
@property
|
||||
def stage_count(self) -> int: ...
|
||||
|
||||
def sampling_defaults(self) -> tuple[object, ...]: ...
|
||||
|
||||
def ensure_request(self, context: DuplexStageRequestContext) -> None: ...
|
||||
|
||||
async def submit(self, submission: DuplexStageSubmission) -> DuplexStageSubmissionResult: ...
|
||||
|
||||
async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: ...
|
||||
|
||||
|
||||
class DuplexControlPlanePort(Protocol):
|
||||
@property
|
||||
def sessions(self) -> object: ...
|
||||
|
||||
def accepts(self, message: object) -> bool: ...
|
||||
|
||||
def dispatch(self, message: object) -> None: ...
|
||||
|
||||
async def shutdown(self) -> None: ...
|
||||
|
||||
def close_sessions_for_request_ids(self, request_ids: list[str]) -> dict[str, list[str]]: ...
|
||||
|
||||
def finalize_closed_sessions(self, session_ids: Iterable[str]) -> None: ...
|
||||
|
||||
def session_for_identity(self, identity: DuplexRequestIdentity | None) -> object | None: ...
|
||||
|
||||
def decide_output(
|
||||
self,
|
||||
stage_id: int,
|
||||
output: object,
|
||||
context: DuplexOutputContext | None,
|
||||
) -> DuplexOutputDecision | None: ...
|
||||
|
||||
|
||||
class CorrelatedRpcTransport(Protocol):
|
||||
def execute(
|
||||
self,
|
||||
key: tuple[str, str],
|
||||
message: EngineQueueMessage,
|
||||
*,
|
||||
timeout: float | None,
|
||||
timeout_message: str,
|
||||
block_on_submit: bool = False,
|
||||
) -> EngineQueueMessage: ...
|
||||
|
||||
|
||||
def duplex_data_plane_request_info(result: dict[str, object]) -> tuple[str | None, int | None]:
|
||||
stage_results = result.get("stage_results")
|
||||
if not isinstance(stage_results, list):
|
||||
return None, None
|
||||
for item in stage_results:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
inner = item.get("result")
|
||||
if not isinstance(inner, dict) or inner.get("data_plane_append") is not True:
|
||||
continue
|
||||
request_id = inner.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id:
|
||||
continue
|
||||
response_stage_id = inner.get("response_stage_id")
|
||||
return request_id, response_stage_id if isinstance(response_stage_id, int) else None
|
||||
return None, None
|
||||
|
||||
|
||||
def duplex_resource_request_id(fence: DuplexFence, role: str) -> str:
|
||||
if not role or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for character in role
|
||||
):
|
||||
raise ValueError(f"invalid duplex resource role: {role!r}")
|
||||
encoded_session_id = base64.urlsafe_b64encode(fence.session_id.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
return f"duplex-s.{encoded_session_id}.i.{fence.incarnation}.e.{fence.epoch}.r.{role}"
|
||||
|
||||
|
||||
def duplex_resource_request_belongs_to_session(request_id: str, session_id: str) -> bool:
|
||||
"""Return whether a current-format resource request belongs to a session."""
|
||||
parts = request_id.split(".")
|
||||
if len(parts) != 8 or parts[0] != "duplex-s" or parts[2] != "i" or parts[4] != "e" or parts[6] != "r":
|
||||
return False
|
||||
try:
|
||||
int(parts[3])
|
||||
int(parts[5])
|
||||
except ValueError:
|
||||
return False
|
||||
role = parts[7]
|
||||
if not role or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for character in role
|
||||
):
|
||||
return False
|
||||
encoded_session_id = base64.urlsafe_b64encode(session_id.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
return parts[1] == encoded_session_id
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CorrelatedRpcTransport",
|
||||
"DuplexAppendPlan",
|
||||
"DuplexControlPlanePort",
|
||||
"DuplexInputMode",
|
||||
"DuplexOutputAction",
|
||||
"DuplexOutputContext",
|
||||
"DuplexOutputDecision",
|
||||
"DuplexRequestIdentity",
|
||||
"DuplexRuntimeCapabilities",
|
||||
"DuplexRuntimeExtension",
|
||||
"DuplexStagePort",
|
||||
"DuplexStageRequestContext",
|
||||
"DuplexStageSubmission",
|
||||
"DuplexStageSubmissionResult",
|
||||
"SessionMode",
|
||||
"duplex_data_plane_request_info",
|
||||
"duplex_resource_request_belongs_to_session",
|
||||
"duplex_resource_request_id",
|
||||
]
|
||||
@@ -0,0 +1,228 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import CorrelatedRpcTransport
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import (
|
||||
AppendDuplexInputMessage,
|
||||
CloseDuplexSessionMessage,
|
||||
DuplexControlResultMessage,
|
||||
DuplexFence,
|
||||
OpenDuplexSessionMessage,
|
||||
ResumeDuplexSessionMessage,
|
||||
SignalDuplexTurnMessage,
|
||||
TouchDuplexSessionMessage,
|
||||
)
|
||||
|
||||
DuplexControlMessage = (
|
||||
OpenDuplexSessionMessage
|
||||
| AppendDuplexInputMessage
|
||||
| SignalDuplexTurnMessage
|
||||
| CloseDuplexSessionMessage
|
||||
| TouchDuplexSessionMessage
|
||||
| ResumeDuplexSessionMessage
|
||||
)
|
||||
|
||||
|
||||
class DuplexControlRequestError(RuntimeError):
|
||||
"""Typed client-side failure returned by the duplex control plane."""
|
||||
|
||||
def __init__(self, result: dict[str, object]) -> None:
|
||||
error = result.get("error")
|
||||
error_data = error if isinstance(error, dict) else {}
|
||||
self.result = result
|
||||
self.code = str(error_data.get("code") or "internal_error")
|
||||
self.retryable = bool(error_data.get("retryable", False))
|
||||
accepted_fence = result.get("accepted_fence")
|
||||
self.accepted_fence = accepted_fence if isinstance(accepted_fence, DuplexFence) else None
|
||||
lease_generation = result.get("lease_generation")
|
||||
self.lease_generation = lease_generation if isinstance(lease_generation, int) else None
|
||||
super().__init__(f"duplex {result.get('operation')} failed: {result}")
|
||||
|
||||
|
||||
class DuplexControlClient:
|
||||
"""Builds duplex commands on top of an engine-owned RPC transport."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: CorrelatedRpcTransport,
|
||||
*,
|
||||
control_id_factory: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
self._transport = transport
|
||||
self._control_id_factory = control_id_factory or (lambda: uuid.uuid4().hex)
|
||||
|
||||
def execute(self, message: DuplexControlMessage, *, timeout: float | None) -> dict[str, object]:
|
||||
result_message = self._transport.execute(
|
||||
("duplex", message.control_id),
|
||||
message,
|
||||
timeout=timeout,
|
||||
timeout_message=f"duplex control timed out after {timeout} seconds",
|
||||
)
|
||||
if not isinstance(result_message, DuplexControlResultMessage):
|
||||
raise RuntimeError(f"unexpected duplex control result type: {type(result_message).__name__}")
|
||||
if result_message.fence != message.fence:
|
||||
raise RuntimeError(
|
||||
f"duplex control fence mismatch: expected {message.fence!r}, got {result_message.fence!r}"
|
||||
)
|
||||
result = {
|
||||
"fence": result_message.fence,
|
||||
"operation": result_message.operation,
|
||||
"session_id": result_message.session_id,
|
||||
"ok": result_message.ok,
|
||||
"stage_results": list(result_message.stage_results),
|
||||
"unsupported_count": result_message.unsupported_count,
|
||||
"error_count": result_message.error_count,
|
||||
"accepted_fence": result_message.accepted_fence,
|
||||
"lease_generation": result_message.lease_generation,
|
||||
"error": (
|
||||
{
|
||||
"code": result_message.error.code,
|
||||
"message": result_message.error.message,
|
||||
"retryable": result_message.error.retryable,
|
||||
}
|
||||
if result_message.error is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if not result_message.ok:
|
||||
raise DuplexControlRequestError(result)
|
||||
return result
|
||||
|
||||
def open(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
session_mode: str,
|
||||
capabilities: dict[str, object] | None,
|
||||
session_config: dict[str, object] | None,
|
||||
runtime_config: dict[str, object] | None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None,
|
||||
) -> dict[str, object]:
|
||||
return self.execute(
|
||||
OpenDuplexSessionMessage(
|
||||
control_id=self._control_id_factory(),
|
||||
fence=fence,
|
||||
session_id=session_id,
|
||||
session_mode=session_mode,
|
||||
capabilities=dict(capabilities or {}),
|
||||
session_config=dict(session_config or {}),
|
||||
runtime_config=dict(runtime_config or {}),
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def append(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
mode: str,
|
||||
payload: object,
|
||||
operation_id: str | None,
|
||||
final: bool,
|
||||
expected_epoch: int | None,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None,
|
||||
) -> dict[str, object]:
|
||||
if expected_epoch is not None and expected_epoch != fence.epoch:
|
||||
raise ValueError("expected_epoch must match fence.epoch")
|
||||
return self.execute(
|
||||
AppendDuplexInputMessage(
|
||||
control_id=self._control_id_factory(),
|
||||
operation_id=operation_id,
|
||||
fence=fence,
|
||||
session_id=session_id,
|
||||
expected_epoch=expected_epoch,
|
||||
mode=mode,
|
||||
payload=payload,
|
||||
final=final,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def signal(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
event: str,
|
||||
fence: DuplexFence,
|
||||
next_fence: DuplexFence | None,
|
||||
session_config: dict[str, object] | None,
|
||||
runtime_config: dict[str, object] | None,
|
||||
timeout: float | None,
|
||||
) -> dict[str, object]:
|
||||
return self.execute(
|
||||
SignalDuplexTurnMessage(
|
||||
control_id=self._control_id_factory(),
|
||||
fence=fence,
|
||||
session_id=session_id,
|
||||
event=event,
|
||||
next_fence=next_fence,
|
||||
session_config=dict(session_config) if session_config is not None else None,
|
||||
runtime_config=dict(runtime_config) if runtime_config is not None else None,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def close(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
fence: DuplexFence,
|
||||
timeout: float | None,
|
||||
) -> dict[str, object]:
|
||||
return self.execute(
|
||||
CloseDuplexSessionMessage(
|
||||
control_id=self._control_id_factory(),
|
||||
fence=fence,
|
||||
session_id=session_id,
|
||||
reason=reason,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def touch(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
activity: DuplexLeaseActivity,
|
||||
timeout: float | None,
|
||||
) -> dict[str, object]:
|
||||
return self.execute(
|
||||
TouchDuplexSessionMessage(
|
||||
control_id=self._control_id_factory(),
|
||||
fence=fence,
|
||||
session_id=session_id,
|
||||
activity=activity.value,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def resume(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
expected_lease_generation: int,
|
||||
timeout: float | None,
|
||||
) -> dict[str, object]:
|
||||
return self.execute(
|
||||
ResumeDuplexSessionMessage(
|
||||
control_id=self._control_id_factory(),
|
||||
fence=fence,
|
||||
session_id=session_id,
|
||||
expected_lease_generation=expected_lease_generation,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["CorrelatedRpcTransport", "DuplexControlClient", "DuplexControlMessage"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,108 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Experimental duplex runtime loading and compatibility exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
DuplexAppendPlan,
|
||||
DuplexInputMode,
|
||||
DuplexOutputAction,
|
||||
DuplexOutputDecision,
|
||||
DuplexRuntimeCapabilities,
|
||||
DuplexRuntimeExtension,
|
||||
SessionMode,
|
||||
duplex_data_plane_request_info,
|
||||
duplex_resource_request_belongs_to_session,
|
||||
duplex_resource_request_id,
|
||||
)
|
||||
|
||||
|
||||
def load_duplex_runtime_extension(path: str | None) -> DuplexRuntimeExtension | None:
|
||||
if not path:
|
||||
return None
|
||||
module_name, separator, attribute_name = path.rpartition(".")
|
||||
if not separator:
|
||||
raise ValueError(f"Invalid duplex runtime extension path: {path!r}")
|
||||
extension_type = getattr(import_module(module_name), attribute_name)
|
||||
return validate_duplex_runtime_extension(extension_type())
|
||||
|
||||
|
||||
def validate_duplex_runtime_extension(
|
||||
extension: object,
|
||||
*,
|
||||
sampling_defaults: tuple[object, ...] | None = None,
|
||||
) -> DuplexRuntimeExtension:
|
||||
required_methods = (
|
||||
"configure_sampling_params",
|
||||
"plan_append",
|
||||
"decide_output",
|
||||
)
|
||||
missing = [name for name in required_methods if not callable(getattr(extension, name, None))]
|
||||
if missing:
|
||||
raise TypeError(f"Duplex runtime extension is missing callable method(s): {', '.join(missing)}")
|
||||
typed_extension = extension # type: ignore[assignment]
|
||||
if sampling_defaults is not None:
|
||||
configured = typed_extension.configure_sampling_params(
|
||||
runtime_config={},
|
||||
defaults=sampling_defaults,
|
||||
)
|
||||
if not isinstance(configured, tuple):
|
||||
raise TypeError("Duplex runtime extension must return sampling parameters as a tuple")
|
||||
if len(configured) != len(sampling_defaults):
|
||||
raise ValueError("Duplex runtime extension must return one sampling parameter per stage")
|
||||
for stage_id, (value, default) in enumerate(zip(configured, sampling_defaults, strict=True)):
|
||||
if default is not None and not isinstance(value, type(default)):
|
||||
raise TypeError(
|
||||
"Duplex runtime extension sampling parameter type mismatch "
|
||||
f"for stage {stage_id}: expected {type(default).__name__}, got {type(value).__name__}"
|
||||
)
|
||||
return typed_extension
|
||||
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_session import ( # noqa: E402, F401
|
||||
DuplexAppendReservation,
|
||||
DuplexCompletedAppend,
|
||||
DuplexFenceMismatchError,
|
||||
DuplexInputAppend,
|
||||
DuplexRequestResource,
|
||||
DuplexSessionRuntimeManager,
|
||||
DuplexSessionRuntimeState,
|
||||
DuplexStageBinding,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import ( # noqa: E402, F401
|
||||
DuplexLeaseActivity,
|
||||
DuplexLeaseConfig,
|
||||
DuplexLeaseState,
|
||||
DuplexSessionExpiry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DuplexAppendPlan",
|
||||
"DuplexAppendReservation",
|
||||
"DuplexCompletedAppend",
|
||||
"DuplexFenceMismatchError",
|
||||
"DuplexInputAppend",
|
||||
"DuplexInputMode",
|
||||
"DuplexLeaseActivity",
|
||||
"DuplexLeaseConfig",
|
||||
"DuplexLeaseState",
|
||||
"DuplexOutputAction",
|
||||
"DuplexOutputDecision",
|
||||
"DuplexRequestResource",
|
||||
"DuplexRuntimeCapabilities",
|
||||
"DuplexRuntimeExtension",
|
||||
"DuplexSessionExpiry",
|
||||
"DuplexSessionRuntimeManager",
|
||||
"DuplexSessionRuntimeState",
|
||||
"DuplexStageBinding",
|
||||
"SessionMode",
|
||||
"duplex_data_plane_request_info",
|
||||
"duplex_resource_request_belongs_to_session",
|
||||
"duplex_resource_request_id",
|
||||
"load_duplex_runtime_extension",
|
||||
"validate_duplex_runtime_extension",
|
||||
]
|
||||
@@ -0,0 +1,534 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
DuplexInputMode,
|
||||
DuplexRuntimeCapabilities,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import (
|
||||
DuplexLeaseActivity,
|
||||
DuplexLeaseConfig,
|
||||
DuplexLeaseState,
|
||||
DuplexSessionExpiry,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
|
||||
def _default_capabilities() -> DuplexRuntimeCapabilities:
|
||||
return DuplexRuntimeCapabilities()
|
||||
|
||||
|
||||
class DuplexFenceMismatchError(RuntimeError):
|
||||
def __init__(self, expected: DuplexFence, actual: DuplexFence) -> None:
|
||||
super().__init__(f"duplex fence mismatch: expected {expected!r}, got {actual!r}")
|
||||
self.expected = expected
|
||||
self.actual = actual
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplexStageBinding:
|
||||
request_id: str
|
||||
fence: DuplexFence
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplexRequestResource:
|
||||
stage_id: int
|
||||
request_id: str
|
||||
fence: DuplexFence
|
||||
submitted: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplexInputAppend:
|
||||
seq: int
|
||||
turn_seq: int
|
||||
turn_id: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexAppendReservation:
|
||||
fence: DuplexFence
|
||||
mode: DuplexInputMode
|
||||
base_fence: DuplexFence
|
||||
base_input_seq: int
|
||||
base_input_turn_seq: int
|
||||
base_append_turn_key: tuple[int, int, int] | None
|
||||
update: DuplexInputAppend
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexCompletedAppend:
|
||||
fence: DuplexFence
|
||||
mode: DuplexInputMode
|
||||
final: bool
|
||||
stage_results: tuple[dict[str, object], ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplexSessionRuntimeState:
|
||||
"""Engine resource handles associated with a core-owned identity fence."""
|
||||
|
||||
fence: DuplexFence
|
||||
lease: DuplexLeaseState
|
||||
_clock: Callable[[], float] = field(repr=False)
|
||||
capabilities: DuplexRuntimeCapabilities = field(default_factory=_default_capabilities)
|
||||
session_config: dict[str, Any] = field(default_factory=dict)
|
||||
runtime_config: dict[str, Any] = field(default_factory=dict)
|
||||
config_generation: int = 0
|
||||
stage_bindings: dict[int, DuplexStageBinding] = field(default_factory=dict)
|
||||
request_resources: dict[tuple[int, str], DuplexRequestResource] = field(default_factory=dict)
|
||||
input_seq: int = 0
|
||||
input_turn_seq: int = 0
|
||||
_append_turn_key: tuple[int, int, int] | None = None
|
||||
completed_append_limit: int = 256
|
||||
completed_appends: OrderedDict[str, DuplexCompletedAppend] = field(default_factory=OrderedDict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.completed_append_limit <= 0:
|
||||
raise ValueError("completed_append_limit must be positive")
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self.fence.session_id
|
||||
|
||||
@property
|
||||
def epoch(self) -> int:
|
||||
return self.fence.epoch
|
||||
|
||||
@property
|
||||
def turn_id(self) -> int:
|
||||
return self.fence.turn_id
|
||||
|
||||
def _validate_fence(self, fence: DuplexFence) -> None:
|
||||
if fence.session_id != self.session_id or fence.incarnation != self.fence.incarnation:
|
||||
raise DuplexFenceMismatchError(self.fence, fence)
|
||||
current = self.fence
|
||||
if fence.epoch < current.epoch or (
|
||||
fence.epoch == current.epoch
|
||||
and (fence.turn_id < current.turn_id or fence.response_seq < current.response_seq)
|
||||
):
|
||||
raise DuplexFenceMismatchError(current, fence)
|
||||
|
||||
def accept_fence(self, fence: DuplexFence) -> None:
|
||||
self._validate_fence(fence)
|
||||
if fence.epoch != self.fence.epoch:
|
||||
self.input_seq = 0
|
||||
self.input_turn_seq = 0
|
||||
self._append_turn_key = None
|
||||
self.completed_appends.clear()
|
||||
self.fence = fence
|
||||
|
||||
def touch(self, fence: DuplexFence, activity: DuplexLeaseActivity) -> None:
|
||||
self._validate_fence(fence)
|
||||
self.lease.touch(self._clock(), activity)
|
||||
|
||||
def detach(self, fence: DuplexFence) -> None:
|
||||
self._validate_fence(fence)
|
||||
self.lease.detach(self._clock())
|
||||
|
||||
def resume(self, fence: DuplexFence, *, expected_lease_generation: int) -> int:
|
||||
self._validate_fence(fence)
|
||||
return self.lease.resume(
|
||||
self._clock(),
|
||||
expected_generation=expected_lease_generation,
|
||||
)
|
||||
|
||||
def begin_operation(self, fence: DuplexFence, operation_id: str) -> None:
|
||||
self._validate_fence(fence)
|
||||
self.lease.begin_operation(self._clock(), operation_id)
|
||||
|
||||
def end_operation(self, fence: DuplexFence, operation_id: str) -> None:
|
||||
self._validate_fence(fence)
|
||||
self.lease.end_operation(self._clock(), operation_id)
|
||||
|
||||
def replace_session_config(self, session_config: dict[str, Any]) -> None:
|
||||
self.session_config = dict(session_config)
|
||||
self.config_generation += 1
|
||||
|
||||
def replace_runtime_config(self, runtime_config: dict[str, Any]) -> None:
|
||||
self.runtime_config = dict(runtime_config)
|
||||
self.config_generation += 1
|
||||
|
||||
def replace_configs(
|
||||
self,
|
||||
*,
|
||||
session_config: dict[str, Any] | None = None,
|
||||
runtime_config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Atomically publish one validated configuration generation."""
|
||||
if session_config is None and runtime_config is None:
|
||||
return
|
||||
next_session_config = self.session_config if session_config is None else dict(session_config)
|
||||
next_runtime_config = self.runtime_config if runtime_config is None else dict(runtime_config)
|
||||
self.session_config = next_session_config
|
||||
self.runtime_config = next_runtime_config
|
||||
self.config_generation += 1
|
||||
|
||||
def reserve_stage_request(self, stage_id: int, request_id: str, *, fence: DuplexFence) -> None:
|
||||
self._validate_fence(fence)
|
||||
resource_key = (stage_id, request_id)
|
||||
existing = self.request_resources.get(resource_key)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.fence.session_id != fence.session_id
|
||||
or existing.fence.incarnation != fence.incarnation
|
||||
or existing.fence.epoch != fence.epoch
|
||||
):
|
||||
raise ValueError(f"Duplex request resource already reserved with different identity: {request_id}")
|
||||
return
|
||||
self.request_resources[resource_key] = DuplexRequestResource(
|
||||
stage_id=stage_id,
|
||||
request_id=request_id,
|
||||
fence=fence,
|
||||
)
|
||||
|
||||
def bind_stage_request(self, stage_id: int, request_id: str, *, fence: DuplexFence) -> None:
|
||||
self.reserve_stage_request(stage_id, request_id, fence=fence)
|
||||
self.accept_fence(fence)
|
||||
resource = self.request_resources[(stage_id, request_id)]
|
||||
resource.fence = fence
|
||||
resource.submitted = True
|
||||
self.stage_bindings[stage_id] = DuplexStageBinding(request_id=request_id, fence=fence)
|
||||
|
||||
def stage_request_ids(self, fence: DuplexFence | None = None) -> list[str]:
|
||||
return [
|
||||
binding.request_id for binding in self.stage_bindings.values() if fence is None or binding.fence == fence
|
||||
]
|
||||
|
||||
def resource_request_ids(
|
||||
self,
|
||||
fence: DuplexFence | None = None,
|
||||
*,
|
||||
submitted: bool | None = None,
|
||||
) -> list[str]:
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
resource.request_id
|
||||
for resource in self.request_resources.values()
|
||||
if (fence is None or resource.fence == fence) and (submitted is None or resource.submitted is submitted)
|
||||
)
|
||||
)
|
||||
|
||||
def release_request_ids(self, request_ids: list[str]) -> None:
|
||||
released = set(request_ids)
|
||||
if not released:
|
||||
return
|
||||
self.stage_bindings = {
|
||||
stage_id: binding for stage_id, binding in self.stage_bindings.items() if binding.request_id not in released
|
||||
}
|
||||
self.request_resources = {
|
||||
resource_key: resource
|
||||
for resource_key, resource in self.request_resources.items()
|
||||
if resource.request_id not in released
|
||||
}
|
||||
|
||||
def prepare_append(self, *, mode: DuplexInputMode, fence: DuplexFence) -> DuplexAppendReservation:
|
||||
if mode not in self.capabilities.input_modes:
|
||||
raise ValueError(f"Duplex input mode {mode.value!r} is not supported by session {self.session_id}")
|
||||
self._validate_fence(fence)
|
||||
input_seq = 0 if fence.epoch != self.fence.epoch else self.input_seq
|
||||
input_turn_seq = 0 if fence.epoch != self.fence.epoch else self.input_turn_seq
|
||||
append_turn_key = None if fence.epoch != self.fence.epoch else self._append_turn_key
|
||||
turn_key = (fence.epoch, fence.turn_id, fence.response_seq)
|
||||
turn_seq = input_turn_seq + 1 if turn_key == append_turn_key else 1
|
||||
return DuplexAppendReservation(
|
||||
fence=fence,
|
||||
mode=mode,
|
||||
base_fence=self.fence,
|
||||
base_input_seq=self.input_seq,
|
||||
base_input_turn_seq=self.input_turn_seq,
|
||||
base_append_turn_key=self._append_turn_key,
|
||||
update=DuplexInputAppend(
|
||||
seq=input_seq + 1,
|
||||
turn_seq=turn_seq,
|
||||
turn_id=fence.turn_id,
|
||||
),
|
||||
)
|
||||
|
||||
def commit_append(self, reservation: DuplexAppendReservation) -> DuplexInputAppend:
|
||||
if (
|
||||
self.fence != reservation.base_fence
|
||||
or self.input_seq != reservation.base_input_seq
|
||||
or self.input_turn_seq != reservation.base_input_turn_seq
|
||||
or self._append_turn_key != reservation.base_append_turn_key
|
||||
):
|
||||
raise RuntimeError("duplex append reservation is stale")
|
||||
self.accept_fence(reservation.fence)
|
||||
self.input_seq = reservation.update.seq
|
||||
self.input_turn_seq = reservation.update.turn_seq
|
||||
self._append_turn_key = (
|
||||
reservation.fence.epoch,
|
||||
reservation.fence.turn_id,
|
||||
reservation.fence.response_seq,
|
||||
)
|
||||
return reservation.update
|
||||
|
||||
def completed_append(
|
||||
self,
|
||||
operation_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
mode: DuplexInputMode,
|
||||
final: bool,
|
||||
) -> list[dict[str, object]] | None:
|
||||
self._validate_fence(fence)
|
||||
completed = self.completed_appends.get(operation_id)
|
||||
if completed is None:
|
||||
return None
|
||||
if completed.fence != fence or completed.mode != mode or completed.final != final:
|
||||
raise ValueError(f"duplex append operation {operation_id!r} was reused with different metadata")
|
||||
return [dict(result) for result in completed.stage_results]
|
||||
|
||||
def record_completed_append(
|
||||
self,
|
||||
operation_id: str,
|
||||
*,
|
||||
fence: DuplexFence,
|
||||
mode: DuplexInputMode,
|
||||
final: bool,
|
||||
stage_results: list[dict[str, object]],
|
||||
) -> None:
|
||||
self.completed_appends[operation_id] = DuplexCompletedAppend(
|
||||
fence=fence,
|
||||
mode=mode,
|
||||
final=final,
|
||||
stage_results=tuple(dict(result) for result in stage_results),
|
||||
)
|
||||
self.completed_appends.move_to_end(operation_id)
|
||||
while len(self.completed_appends) > self.completed_append_limit:
|
||||
self.completed_appends.popitem(last=False)
|
||||
|
||||
def append_input(self, *, mode: DuplexInputMode, fence: DuplexFence) -> DuplexInputAppend:
|
||||
return self.commit_append(self.prepare_append(mode=mode, fence=fence))
|
||||
|
||||
def release_fence(self, fence: DuplexFence) -> list[str]:
|
||||
stale = self.resource_request_ids(fence)
|
||||
self.stage_bindings = {
|
||||
stage_id: binding for stage_id, binding in self.stage_bindings.items() if binding.fence != fence
|
||||
}
|
||||
self.request_resources = {
|
||||
resource_key: resource
|
||||
for resource_key, resource in self.request_resources.items()
|
||||
if resource.fence != fence
|
||||
}
|
||||
return stale
|
||||
|
||||
def cancel_fence(self, cancelled_fence: DuplexFence, next_fence: DuplexFence) -> list[str]:
|
||||
stale = self.prepare_cancel_fence(cancelled_fence, next_fence)
|
||||
self.release_fence(cancelled_fence)
|
||||
return stale
|
||||
|
||||
def prepare_cancel_fence(self, cancelled_fence: DuplexFence, next_fence: DuplexFence) -> list[str]:
|
||||
"""Advance the cancellation fence without dropping cleanup records."""
|
||||
if cancelled_fence.session_id != self.session_id or cancelled_fence.incarnation != self.fence.incarnation:
|
||||
raise DuplexFenceMismatchError(self.fence, cancelled_fence)
|
||||
if (
|
||||
next_fence.session_id != self.session_id
|
||||
or next_fence.incarnation != self.fence.incarnation
|
||||
or next_fence.epoch <= cancelled_fence.epoch
|
||||
):
|
||||
raise DuplexFenceMismatchError(cancelled_fence, next_fence)
|
||||
current_key = (self.fence.epoch, self.fence.turn_id, self.fence.response_seq)
|
||||
cancelled_key = (cancelled_fence.epoch, cancelled_fence.turn_id, cancelled_fence.response_seq)
|
||||
next_key = (next_fence.epoch, next_fence.turn_id, next_fence.response_seq)
|
||||
if cancelled_key > current_key:
|
||||
raise DuplexFenceMismatchError(self.fence, cancelled_fence)
|
||||
if next_key > current_key:
|
||||
self.accept_fence(next_fence)
|
||||
return self.resource_request_ids(cancelled_fence)
|
||||
|
||||
def begin_close(self, fence: DuplexFence, *, reason: str) -> bool:
|
||||
"""Make close irreversible while retaining resources for cleanup retry."""
|
||||
self.accept_fence(fence)
|
||||
if self.lease.terminal_reason is not None:
|
||||
return True
|
||||
return self.lease.mark_terminal(reason)
|
||||
|
||||
def finalize_close(self) -> list[str]:
|
||||
return self.close()
|
||||
|
||||
def close(self, fence: DuplexFence | None = None) -> list[str]:
|
||||
if fence is not None:
|
||||
self.accept_fence(fence)
|
||||
stale = self.resource_request_ids()
|
||||
self.stage_bindings.clear()
|
||||
self.request_resources.clear()
|
||||
return stale
|
||||
|
||||
def terminate(self, fence: DuplexFence, *, reason: str) -> bool:
|
||||
if not self.begin_close(fence, reason=reason):
|
||||
return False
|
||||
self.finalize_close()
|
||||
return True
|
||||
|
||||
|
||||
class DuplexSessionRuntimeManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
clock: Callable[[], float] | None = None,
|
||||
max_sessions: int | None = None,
|
||||
completed_append_limit: int = 256,
|
||||
) -> None:
|
||||
if max_sessions is not None and max_sessions <= 0:
|
||||
raise ValueError("max_sessions must be positive or null")
|
||||
if completed_append_limit <= 0:
|
||||
raise ValueError("completed_append_limit must be positive")
|
||||
self._sessions: dict[str, DuplexSessionRuntimeState] = {}
|
||||
self._clock = clock or time.monotonic
|
||||
self._max_sessions = max_sessions
|
||||
self._completed_append_limit = completed_append_limit
|
||||
|
||||
def open_session(
|
||||
self,
|
||||
fence: DuplexFence,
|
||||
*,
|
||||
capabilities: DuplexRuntimeCapabilities | None = None,
|
||||
session_config: dict[str, Any] | None = None,
|
||||
runtime_config: dict[str, Any] | None = None,
|
||||
lease_config: DuplexLeaseConfig | None = None,
|
||||
) -> DuplexSessionRuntimeState:
|
||||
if not isinstance(fence, DuplexFence):
|
||||
raise TypeError("open_session requires DuplexFence")
|
||||
if fence.session_id in self._sessions:
|
||||
raise ValueError(f"Duplex session already exists: {fence.session_id}")
|
||||
if self._max_sessions is not None and len(self._sessions) >= self._max_sessions:
|
||||
raise RuntimeError(f"duplex_session_capacity_exhausted: limit={self._max_sessions}")
|
||||
session = DuplexSessionRuntimeState(
|
||||
fence=fence,
|
||||
lease=DuplexLeaseState(
|
||||
config=lease_config or DuplexLeaseConfig(),
|
||||
generation=0,
|
||||
last_activity=self._clock(),
|
||||
),
|
||||
_clock=self._clock,
|
||||
capabilities=capabilities or _default_capabilities(),
|
||||
session_config=dict(session_config or {}),
|
||||
runtime_config=dict(runtime_config or {}),
|
||||
completed_append_limit=self._completed_append_limit,
|
||||
)
|
||||
self._sessions[fence.session_id] = session
|
||||
return session
|
||||
|
||||
def get(self, session_id: str) -> DuplexSessionRuntimeState | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def require(self, session_id: str) -> DuplexSessionRuntimeState:
|
||||
session = self.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError(f"Unknown duplex session: {session_id}")
|
||||
return session
|
||||
|
||||
def close_session(
|
||||
self,
|
||||
fence: DuplexFence,
|
||||
*,
|
||||
reason: str = "explicit_close",
|
||||
) -> DuplexSessionRuntimeState | None:
|
||||
if not isinstance(fence, DuplexFence):
|
||||
raise TypeError("close_session requires DuplexFence")
|
||||
session = self._sessions.get(fence.session_id)
|
||||
if session is not None:
|
||||
if session.lease.terminal_reason is not None:
|
||||
return None
|
||||
if not session.terminate(fence, reason=reason):
|
||||
return None
|
||||
if self._sessions.get(fence.session_id) is session:
|
||||
self._sessions.pop(fence.session_id)
|
||||
return session
|
||||
|
||||
def begin_close_session(
|
||||
self,
|
||||
fence: DuplexFence,
|
||||
*,
|
||||
reason: str = "explicit_close",
|
||||
) -> DuplexSessionRuntimeState | None:
|
||||
if not isinstance(fence, DuplexFence):
|
||||
raise TypeError("begin_close_session requires DuplexFence")
|
||||
session = self._sessions.get(fence.session_id)
|
||||
if session is not None:
|
||||
session.begin_close(fence, reason=reason)
|
||||
return session
|
||||
|
||||
def finalize_close_session(self, session: DuplexSessionRuntimeState) -> None:
|
||||
session.finalize_close()
|
||||
if self._sessions.get(session.session_id) is session:
|
||||
self._sessions.pop(session.session_id, None)
|
||||
|
||||
def collect_expired(
|
||||
self,
|
||||
now: float | None = None,
|
||||
*,
|
||||
excluded_session_ids: set[str] | None = None,
|
||||
) -> list[DuplexSessionExpiry]:
|
||||
effective_now = self._clock() if now is None else now
|
||||
excluded = excluded_session_ids or set()
|
||||
expired: list[DuplexSessionExpiry] = []
|
||||
for session_id, session in list(self._sessions.items()):
|
||||
if session_id in excluded:
|
||||
continue
|
||||
if session.lease.disconnect_grace_expired(effective_now):
|
||||
reason = "disconnect_grace_expired"
|
||||
elif session.lease.idle_expired(effective_now):
|
||||
reason = "idle_ttl_expired"
|
||||
else:
|
||||
continue
|
||||
submitted = tuple(session.resource_request_ids(submitted=True))
|
||||
reserved = tuple(session.resource_request_ids(submitted=False))
|
||||
if not session.begin_close(session.fence, reason=reason):
|
||||
continue
|
||||
expired.append(
|
||||
DuplexSessionExpiry(
|
||||
session_id=session_id,
|
||||
fence=session.fence,
|
||||
lease_generation=session.lease.generation,
|
||||
reason=reason,
|
||||
submitted_request_ids=submitted,
|
||||
reserved_request_ids=reserved,
|
||||
)
|
||||
)
|
||||
return expired
|
||||
|
||||
def close_sessions_for_request_ids(self, request_ids: list[str]) -> dict[str, list[str]]:
|
||||
request_id_set = set(request_ids)
|
||||
closed: dict[str, list[str]] = {}
|
||||
for session_id, session in list(self._sessions.items()):
|
||||
stale = session.resource_request_ids()
|
||||
if request_id_set.isdisjoint(stale):
|
||||
continue
|
||||
if not session.begin_close(session.fence, reason="request_cleanup"):
|
||||
continue
|
||||
closed[session_id] = stale
|
||||
return closed
|
||||
|
||||
def finalize_closed_sessions(self, session_ids: Iterable[str]) -> None:
|
||||
for session_id in session_ids:
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None or session.lease.terminal_reason is None:
|
||||
continue
|
||||
self.finalize_close_session(session)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DuplexAppendReservation",
|
||||
"DuplexCompletedAppend",
|
||||
"DuplexFenceMismatchError",
|
||||
"DuplexInputAppend",
|
||||
"DuplexLeaseActivity",
|
||||
"DuplexLeaseConfig",
|
||||
"DuplexLeaseState",
|
||||
"DuplexRequestResource",
|
||||
"DuplexSessionExpiry",
|
||||
"DuplexSessionRuntimeManager",
|
||||
"DuplexSessionRuntimeState",
|
||||
"DuplexStageBinding",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class DuplexIntermediateBuffer(TypedDict, total=False):
|
||||
"""Structured keys carried through ``model_intermediate_buffer``.
|
||||
|
||||
The buffer remains a dict for scheduler and msgspec compatibility, but
|
||||
duplex-specific producers and consumers should use the helpers in this
|
||||
module instead of scattering nested string keys across serving, runner, and
|
||||
model code.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
global_request_id: list[str]
|
||||
prompt_token_ids: list[int]
|
||||
llm_output_token_ids: list[int]
|
||||
llm_output_text: list[str]
|
||||
stream_output: bool
|
||||
native_duplex: bool
|
||||
ids: dict[str, object]
|
||||
hidden_states: dict[str, object]
|
||||
codes: dict[str, object]
|
||||
meta: dict[str, object]
|
||||
duplex: dict[str, object]
|
||||
omni_payload: object
|
||||
waveform: object
|
||||
mel_spec: object
|
||||
|
||||
|
||||
def build_duplex_intermediate_buffer(
|
||||
*,
|
||||
request_id: str,
|
||||
prompt_token_ids: list[int] | None = None,
|
||||
output_token_ids: list[int] | None = None,
|
||||
output_text: str | None = None,
|
||||
stream_output: bool = False,
|
||||
native_duplex: bool = False,
|
||||
) -> DuplexIntermediateBuffer:
|
||||
buffer: DuplexIntermediateBuffer = {
|
||||
"global_request_id": [str(request_id)],
|
||||
"ids": {},
|
||||
}
|
||||
if prompt_token_ids is not None:
|
||||
prompt_ids = [int(token_id) for token_id in prompt_token_ids]
|
||||
buffer["prompt_token_ids"] = prompt_ids
|
||||
buffer["ids"]["prompt"] = prompt_ids
|
||||
if output_token_ids is not None:
|
||||
output_ids = [int(token_id) for token_id in output_token_ids]
|
||||
buffer["llm_output_token_ids"] = output_ids
|
||||
buffer["ids"]["output"] = output_ids
|
||||
if output_text is not None:
|
||||
buffer["llm_output_text"] = [output_text]
|
||||
if stream_output:
|
||||
buffer["stream_output"] = True
|
||||
if native_duplex:
|
||||
buffer["native_duplex"] = True
|
||||
return buffer
|
||||
|
||||
|
||||
def set_ref_audio(buffer: dict[str, object], waveform: object, sample_rate_hz: int) -> None:
|
||||
buffer.setdefault("codes", {})["ref"] = waveform
|
||||
buffer.setdefault("meta", {})["ref_audio_sr"] = int(sample_rate_hz)
|
||||
|
||||
|
||||
def set_tts_handoff(buffer: dict[str, object], token_ids: object | None, hidden_states: object | None) -> None:
|
||||
"""Store the generic AR-stage token/hidden-state handoff consumed by TTS."""
|
||||
if token_ids is not None:
|
||||
buffer.setdefault("ids", {})["tts"] = token_ids
|
||||
if hidden_states is not None:
|
||||
buffer.setdefault("hidden_states", {})["tts"] = hidden_states
|
||||
|
||||
|
||||
def get_tts_handoff(info: dict[str, object]) -> tuple[object | None, object | None]:
|
||||
"""Read the generic AR-stage to TTS handoff, including legacy flat keys."""
|
||||
ids_info = info.get("ids")
|
||||
hidden_info = info.get("hidden_states")
|
||||
tts_token_ids = ids_info.get("tts") if isinstance(ids_info, dict) else None
|
||||
tts_hidden_states = hidden_info.get("tts") if isinstance(hidden_info, dict) else None
|
||||
if tts_token_ids is None:
|
||||
tts_token_ids = info.get("tts_token_ids")
|
||||
if tts_hidden_states is None:
|
||||
tts_hidden_states = info.get("tts_hidden_states")
|
||||
return tts_token_ids, tts_hidden_states
|
||||
|
||||
|
||||
def get_stream_request_key(info: dict[str, object]) -> str:
|
||||
key = info.get("global_request_id") or info.get("request_id") or info.get("_omni_req_id")
|
||||
if isinstance(key, (list, tuple)):
|
||||
key = key[0] if key else None
|
||||
if isinstance(key, bytes):
|
||||
key = key.decode("utf-8", errors="replace")
|
||||
if key is None:
|
||||
raise ValueError(
|
||||
"Duplex streaming handoff requires a stable request id; "
|
||||
"expected global_request_id, request_id, or _omni_req_id."
|
||||
)
|
||||
return str(key)
|
||||
@@ -0,0 +1,129 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Generic engine lease primitives shared by optional session runtimes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
|
||||
class DuplexLeaseActivity(str, Enum):
|
||||
APPEND = "append"
|
||||
SIGNAL = "signal"
|
||||
PLAYBACK_ACK = "playback_ack"
|
||||
HEARTBEAT = "heartbeat"
|
||||
ATTACH = "attach"
|
||||
DETACH = "detach"
|
||||
RESUME = "resume"
|
||||
MODEL_OUTPUT = "model_output"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexLeaseConfig:
|
||||
idle_ttl_s: float | None = 300.0
|
||||
disconnect_grace_s: float = 30.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.idle_ttl_s is not None and self.idle_ttl_s <= 0:
|
||||
raise ValueError("idle_ttl_s must be positive or null")
|
||||
if self.disconnect_grace_s <= 0:
|
||||
raise ValueError("disconnect_grace_s must be positive")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplexLeaseState:
|
||||
config: DuplexLeaseConfig
|
||||
generation: int
|
||||
last_activity: float
|
||||
detached_at: float | None = None
|
||||
active_operations: set[str] = field(default_factory=set)
|
||||
terminal_reason: str | None = None
|
||||
|
||||
@property
|
||||
def expires_at(self) -> float | None:
|
||||
if self.config.idle_ttl_s is None:
|
||||
return None
|
||||
return self.last_activity + self.config.idle_ttl_s
|
||||
|
||||
def _require_open(self) -> None:
|
||||
if self.terminal_reason is not None:
|
||||
raise RuntimeError(f"duplex lease is terminal: {self.terminal_reason}")
|
||||
|
||||
def touch(self, now: float, activity: DuplexLeaseActivity) -> None:
|
||||
self._require_open()
|
||||
if not isinstance(activity, DuplexLeaseActivity):
|
||||
raise TypeError("lease activity must be DuplexLeaseActivity")
|
||||
self.last_activity = now
|
||||
|
||||
def detach(self, now: float) -> None:
|
||||
self.touch(now, DuplexLeaseActivity.DETACH)
|
||||
self.detached_at = now
|
||||
|
||||
def resume(self, now: float, *, expected_generation: int) -> int:
|
||||
self._require_open()
|
||||
if expected_generation != self.generation:
|
||||
raise ValueError(f"duplex lease generation mismatch: expected {self.generation}, got {expected_generation}")
|
||||
self.generation += 1
|
||||
self.detached_at = None
|
||||
self.last_activity = now
|
||||
return self.generation
|
||||
|
||||
def begin_operation(self, now: float, operation_id: str) -> None:
|
||||
self._require_open()
|
||||
if not operation_id:
|
||||
raise ValueError("duplex lease operation_id must not be empty")
|
||||
if operation_id in self.active_operations:
|
||||
raise ValueError(f"duplex lease operation already active: {operation_id}")
|
||||
self.active_operations.add(operation_id)
|
||||
self.last_activity = now
|
||||
|
||||
def end_operation(self, now: float, operation_id: str) -> None:
|
||||
self._require_open()
|
||||
if operation_id not in self.active_operations:
|
||||
raise KeyError(f"duplex lease operation is not active: {operation_id}")
|
||||
self.active_operations.remove(operation_id)
|
||||
self.last_activity = now
|
||||
|
||||
def disconnect_grace_expired(self, now: float) -> bool:
|
||||
return (
|
||||
self.terminal_reason is None
|
||||
and self.detached_at is not None
|
||||
and now >= self.detached_at + self.config.disconnect_grace_s
|
||||
)
|
||||
|
||||
def idle_expired(self, now: float) -> bool:
|
||||
expires_at = self.expires_at
|
||||
return (
|
||||
self.terminal_reason is None and not self.active_operations and expires_at is not None and now >= expires_at
|
||||
)
|
||||
|
||||
def mark_terminal(self, reason: str) -> bool:
|
||||
if self.terminal_reason is not None:
|
||||
return False
|
||||
self.terminal_reason = reason
|
||||
self.generation += 1
|
||||
self.detached_at = None
|
||||
self.active_operations.clear()
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplexSessionExpiry:
|
||||
session_id: str
|
||||
fence: DuplexFence
|
||||
lease_generation: int
|
||||
reason: str
|
||||
submitted_request_ids: tuple[str, ...]
|
||||
reserved_request_ids: tuple[str, ...]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DuplexLeaseActivity",
|
||||
"DuplexLeaseConfig",
|
||||
"DuplexLeaseState",
|
||||
"DuplexSessionExpiry",
|
||||
]
|
||||
@@ -0,0 +1,130 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Queue contracts owned by the experimental duplex control plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from vllm_omni.engine.messages import EngineQueueMessage
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DuplexFence:
|
||||
"""Identity fence carried by experimental duplex control messages."""
|
||||
|
||||
session_id: str
|
||||
epoch: int = 0
|
||||
turn_id: int = 0
|
||||
response_seq: int = 0
|
||||
incarnation: int = 0
|
||||
|
||||
|
||||
class OpenDuplexSessionMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["open_duplex_session"] = "open_duplex_session"
|
||||
control_id: str
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
session_mode: str = "duplex"
|
||||
capabilities: dict[str, object]
|
||||
session_config: dict[str, object] | None = None
|
||||
runtime_config: dict[str, object] | None = None
|
||||
|
||||
|
||||
class AppendDuplexInputMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["append_duplex_input"] = "append_duplex_input"
|
||||
control_id: str
|
||||
operation_id: str | None = None
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
expected_epoch: int | None = None
|
||||
mode: str
|
||||
payload: object
|
||||
final: bool = False
|
||||
|
||||
|
||||
class SignalDuplexTurnMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["signal_duplex_turn"] = "signal_duplex_turn"
|
||||
control_id: str
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
event: str
|
||||
next_fence: DuplexFence | None = None
|
||||
session_config: dict[str, object] | None = None
|
||||
runtime_config: dict[str, object] | None = None
|
||||
|
||||
|
||||
class CloseDuplexSessionMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["close_duplex_session"] = "close_duplex_session"
|
||||
control_id: str
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
reason: str = "client_close"
|
||||
|
||||
|
||||
class TouchDuplexSessionMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["touch_duplex_session"] = "touch_duplex_session"
|
||||
control_id: str
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
activity: str
|
||||
|
||||
|
||||
class ResumeDuplexSessionMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["resume_duplex_session"] = "resume_duplex_session"
|
||||
control_id: str
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
expected_lease_generation: int
|
||||
|
||||
|
||||
class DuplexSessionLifecycleMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["duplex_session_lifecycle"] = "duplex_session_lifecycle"
|
||||
fence: DuplexFence
|
||||
session_id: str
|
||||
event: str
|
||||
reason: str
|
||||
lease_generation: int
|
||||
submitted_request_ids: list[str]
|
||||
reserved_request_ids: list[str]
|
||||
|
||||
|
||||
class DuplexControlError(EngineQueueMessage, kw_only=True):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
|
||||
|
||||
class DuplexControlResultMessage(EngineQueueMessage, kw_only=True):
|
||||
type: Literal["duplex_control_result"] = "duplex_control_result"
|
||||
control_id: str
|
||||
fence: DuplexFence
|
||||
operation: str
|
||||
session_id: str
|
||||
ok: bool
|
||||
stage_results: list[dict[str, object]]
|
||||
unsupported_count: int = 0
|
||||
error_count: int = 0
|
||||
error: DuplexControlError | None = None
|
||||
accepted_fence: DuplexFence | None = None
|
||||
lease_generation: int | None = None
|
||||
|
||||
@property
|
||||
def rpc_correlation_key(self) -> tuple[str, str]:
|
||||
return ("duplex", self.control_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AppendDuplexInputMessage",
|
||||
"CloseDuplexSessionMessage",
|
||||
"DuplexControlError",
|
||||
"DuplexControlResultMessage",
|
||||
"DuplexFence",
|
||||
"DuplexSessionLifecycleMessage",
|
||||
"OpenDuplexSessionMessage",
|
||||
"ResumeDuplexSessionMessage",
|
||||
"SignalDuplexTurnMessage",
|
||||
"TouchDuplexSessionMessage",
|
||||
]
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user