[Core] Generalized turn-based /v1/realtime implementation (#8339)

Signed-off-by: Nick Cao <ncao@redhat.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Nick CaoandCodex authored and GitHub committed 2026-10-06 22:07:31 +08:00
1 parent 25fb257667
commit 65699fe94c
16 files changed
+716 -799

No files matched your search

@@ -18,8 +18,7 @@ from vllm.entrypoints.openai.models.protocol import BaseModelPath
from vllm_omni.config import stage_config
from vllm_omni.config.config_factory import StageConfigFactory
from vllm_omni.config.omni_config import VllmOmniConfig
from vllm_omni.config.stage_config import DeployConfig, DuplexSessionRuntimeConfig, PipelineConfig, StagePipelineConfig
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig, PipelineConfig, StagePipelineConfig
from vllm_omni.engine.duplex.config import DuplexCapabilities
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
from vllm_omni.entrypoints.duplex_omni import DuplexOmni
@@ -58,14 +57,12 @@ _DUPLEX_APP_STATE_KEYS = {
"openai_streaming_speech",
"openai_streaming_video",
"openai_streaming_video_output",
"openai_serving_realtime",
"openai_serving_realtime_robot",
"anthropic_serving_messages",
"openai_serving_duplex",
"enable_server_load_tracking",
"server_load_metrics",
}
#: Every turn-based service, plus the Realtime route that is not the duplex one.
#: ``openai_serving_chat`` is absent: a duplex engine also serves chat, through
#: the ordinary turn-based service, because DuplexOmni extends AsyncOmni.
#: ``online_renderer`` is absent for the same reason -- the chat service needs it.
@@ -370,11 +367,31 @@ def _duplex_app(handler: object) -> FastAPI:
app = FastAPI()
app.include_router(api_server.router)
app.state.openai_serving_duplex = handler
# A duplex server serves no turn-based Realtime route.
app.state.openai_serving_realtime = None
return app
def _turn_based_realtime_app(handler: object | None, mocker) -> FastAPI:
app = _duplex_app(handler)
app.state.args = Namespace()
app.state.openai_serving_models = _FakeModels(
base_model_paths=[BaseModelPath(name="omni-model", model_path="omni-model")]
)
app.state.engine_client = mocker.Mock()
app.state.openai_serving_chat = mocker.Mock()
return app
def _assert_openai_realtime_connection(client: TestClient, path: str) -> None:
with client.websocket_connect(path) as websocket:
assert websocket.receive_json()["type"] == "session.created"
assert websocket.receive_json()["type"] == "conversation.created"
websocket.send_json({"type": "unknown_event"})
error = websocket.receive_json()
assert error["type"] == "error"
assert error["error"]["type"] == "invalid_request_error"
assert error["error"]["code"] == "invalid_event"
@pytest.mark.parametrize("path", ["/v1/duplex", "/v1/realtime?duplex=1"])
def test_both_duplex_routes_reach_the_session_handler(path: str) -> None:
"""``/v1/duplex`` is an alias of ``/v1/realtime?duplex=1``: same handler, same protocol."""
@@ -423,73 +440,21 @@ def test_realtime_without_a_duplex_flag_reaches_the_session_handler(query: str)
@pytest.mark.parametrize("query", ["?duplex=0", "?duplex=false", "?duplex=off"])
def test_realtime_opted_out_of_duplex_falls_through_to_the_turn_based_route(query: str) -> None:
"""An explicit opt-out selects the legacy handler, which a duplex server does not mount."""
def test_realtime_opted_out_of_duplex_falls_through_to_the_turn_based_route(query: str, mocker) -> None:
handler = _RecordingHandler()
app = _turn_based_realtime_app(handler, mocker)
with TestClient(_duplex_app(handler)) as client:
with client.websocket_connect(f"/v1/realtime{query}") as websocket:
assert websocket.receive_json() == {
"type": "error",
"error": "Realtime API is not available",
"code": "unsupported",
}
with pytest.raises(WebSocketDisconnect):
websocket.receive_text()
with TestClient(app) as client:
_assert_openai_realtime_connection(client, f"/v1/realtime{query}")
assert handler.queries == []
@pytest.fixture
def qwen3_realtime_stages():
from vllm_omni.model_executor.models.qwen3_omni.pipeline import QWEN3_OMNI_PIPELINE
return VllmOmniConfig.from_pipeline_config(
QWEN3_OMNI_PIPELINE, user_deploy_config=DeployConfig(async_chunk=True)
).stage_configs
def test_qwen3_realtime_recognizes_typed_stages(qwen3_realtime_stages) -> None:
from vllm_omni.entrypoints.duplex.openai import supports_qwen3_omni_realtime
assert supports_qwen3_omni_realtime(qwen3_realtime_stages)
@pytest.mark.parametrize("topology", ["missing-stage", "duplicate-stage", "other-architecture"])
def test_qwen3_realtime_rejects_other_topologies(qwen3_realtime_stages, topology: str) -> None:
from vllm_omni.entrypoints.duplex.openai import supports_qwen3_omni_realtime
stages = list(qwen3_realtime_stages)
if topology == "missing-stage":
stages.pop()
elif topology == "duplicate-stage":
stages[-1] = stages[0]
else:
stages[0].model_config.model_arch = "Qwen2_5OmniForConditionalGeneration"
assert not supports_qwen3_omni_realtime(stages)
def test_qwen3_turn_deployment_uses_conformant_realtime(qwen3_realtime_stages, mocker) -> None:
app = _duplex_app(None)
app.state.stage_configs = qwen3_realtime_stages
app.state.args = Namespace()
app.state.openai_serving_models = _FakeModels(
base_model_paths=[BaseModelPath(name="qwen3-omni", model_path="qwen3-omni")]
)
app.state.engine_client = mocker.Mock(
get_tokenizer=mocker.AsyncMock(return_value=mocker.Mock(chat_template="{{ messages }}"))
)
def test_turn_based_deployment_uses_openai_realtime(mocker) -> None:
app = _turn_based_realtime_app(None, mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime?model=qwen3-omni") as websocket:
assert websocket.receive_json()["type"] == "session.created"
assert websocket.receive_json()["type"] == "conversation.created"
websocket.send_json({"type": "unknown_event"})
error = websocket.receive_json()
assert error["type"] == "error"
assert error["error"]["type"] == "invalid_request_error"
assert error["error"]["code"] == "invalid_event"
_assert_openai_realtime_connection(client, "/v1/realtime?model=omni-model")
# --------------------------------------------------------------------------- #
@@ -560,8 +525,6 @@ def test_qwen_plugin_preserves_custom_turn_deployment_default(monkeypatch, tmp_p
@pytest.mark.parametrize("flag", ["1", "true", "on"])
def test_turn_deployment_rejects_explicit_duplex_without_falling_back_to_stt(flag: str) -> None:
app = _duplex_app(None)
# Even an available STT service must not silently accept a duplex request.
app.state.openai_serving_realtime = object()
with TestClient(app) as client:
with client.websocket_connect(f"/v1/realtime?duplex={flag}") as websocket:
assert websocket.receive_json() == {