[P0][RFC #3747] RL rollout serving: session management + world_model_env (#4770)

Signed-off-by: srinivas_oo7 <sklinkedin0120@gmail.com>
Co-authored-by: srinivas_oo7 <sklinkedin0120@gmail.com>
Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
This commit is contained in:
authored and GitHub committed 2026-09-14 09:26:44 +08:00
1 parent 6505577459
commit 8e738ce21b
11 files changed
+1421 -1

No files matched your search

@@ -80,6 +80,11 @@ _EXPECTED_ROUTER_ROUTES = {
("WEBSOCKET", "/v1/realtime"),
("WEBSOCKET", "/v1/realtime/robot/openpi"),
("WEBSOCKET", "/v1/duplex"),
("POST", "/v1/realtime/sessions"),
("POST", "/v1/realtime/sessions/{session_id}/step"),
("POST", "/v1/realtime/sessions/{session_id}/reset"),
("POST", "/v1/realtime/sessions/{session_id}/close"),
("GET", "/v1/realtime/sessions/{session_id}/status"),
("GET", "/health"),
("GET", "/v1/models"),
("POST", "/v1/images/generations"),
@@ -120,6 +125,7 @@ _DIFFUSION_APP_STATE_KEYS = {
"openai_streaming_speech",
"openai_streaming_video",
"openai_serving_realtime_robot",
"rl_rollout_serving",
"enable_server_load_tracking",
"server_load_metrics",
}
@@ -151,12 +157,14 @@ _MULTISTAGE_APP_STATE_KEYS = {
"openai_serving_realtime",
"openai_serving_video",
"openai_serving_realtime_robot",
"rl_rollout_serving",
"enable_server_load_tracking",
"server_load_metrics",
}
_MULTISTAGE_MUST_BE_NONE = {
"openai_serving_duplex",
"openai_serving_realtime_robot",
"rl_rollout_serving",
}
_MULTISTAGE_MUST_BE_WIRED = _MULTISTAGE_APP_STATE_KEYS - _MULTISTAGE_MUST_BE_NONE
@@ -440,3 +440,17 @@ def test_extract_actions_does_not_iterate_result_object():
actions = serving._extract_actions(IterableResult())
np.testing.assert_allclose(actions, np.array([[1.0, 2.0, 3.0]], dtype=np.float32))
def test_drop_session_calls_dreamzero_close_hook():
closed: list[str] = []
pipeline = SimpleNamespace(close_ar_diffusion_session=lambda session_id: closed.append(session_id))
engine_client = SimpleNamespace(
model_config={"policy_server_config": TEST_POLICY_SERVER_CONFIG},
model_runner=SimpleNamespace(pipeline=pipeline),
)
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=engine_client)
serving.drop_session("rollout-1")
assert closed == ["rollout-1"]
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+167
View File
@@ -0,0 +1,167 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Route-level tests for RL rollout serving endpoints."""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from vllm_omni.entrypoints.openai.api_server import router
from vllm_omni.entrypoints.openai.protocol.rollout import (
ErrorObject,
RolloutStepResponse,
SessionMetadata,
)
from vllm_omni.entrypoints.openai.rollout_session import (
RolloutSessionClosedError,
RolloutSessionNotFoundError,
)
class _FakeRolloutServing:
def __init__(self, close_error: Exception | None = None, step_error_code: str | None = None) -> None:
self.close_error = close_error
self.step_error_code = step_error_code
self.step_request = None
async def step(self, session_id, body):
self.step_request = body
error = None
if self.step_error_code is not None:
error = ErrorObject(
code=self.step_error_code,
message=f"{self.step_error_code}: {session_id}",
step_id=body.step_id,
committed_step_id=-1,
)
return RolloutStepResponse(
step_id=body.step_id,
next_observation=None,
model_metadata=SessionMetadata(
latency_ms=0.0,
steps_generated=0,
context_length=0,
committed_step_id=-1,
),
error=error,
)
async def close_session(self, session_id):
if self.close_error is not None:
raise self.close_error
def _client(serving: _FakeRolloutServing | None = None) -> TestClient:
app = FastAPI()
app.include_router(router)
if serving is not None:
app.state.rl_rollout_serving = serving
return TestClient(app)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_step_route_rejects_base64_image_payload():
serving = _FakeRolloutServing()
client = _client(serving)
response = client.post(
"/v1/realtime/sessions/s1/step",
json={
"step_id": 0,
"observation": {"images": {"front": "base64-frames"}},
"action": {},
},
)
assert response.status_code == 422
assert serving.step_request is None
def test_step_route_accepts_nested_image_payload():
serving = _FakeRolloutServing()
client = _client(serving)
response = client.post(
"/v1/realtime/sessions/s1/step",
json={
"step_id": 0,
"observation": {"images": {"observation/exterior_image_1_left": [[[0, 0, 0]]]}},
"action": {},
},
)
assert response.status_code == 200
assert serving.step_request.observation.images == {"observation/exterior_image_1_left": [[[0, 0, 0]]]}
def test_step_route_requires_action():
client = _client(_FakeRolloutServing())
response = client.post(
"/v1/realtime/sessions/s1/step",
json={"step_id": 0, "observation": {}},
)
assert response.status_code == 422
def test_step_route_maps_missing_session_to_404():
client = _client(_FakeRolloutServing(step_error_code="session_not_found"))
response = client.post(
"/v1/realtime/sessions/s1/step",
json={"step_id": 0, "observation": {}, "action": {}},
)
assert response.status_code == 404
def test_step_route_maps_closed_session_to_410():
client = _client(_FakeRolloutServing(step_error_code="session_closed"))
response = client.post(
"/v1/realtime/sessions/s1/step",
json={"step_id": 0, "observation": {}, "action": {}},
)
assert response.status_code == 410
def test_step_route_maps_invalid_request_to_400():
client = _client(_FakeRolloutServing(step_error_code="invalid_request"))
response = client.post(
"/v1/realtime/sessions/s1/step",
json={"step_id": 0, "observation": {}, "action": {}},
)
assert response.status_code == 400
def test_close_route_maps_missing_session_to_404():
client = _client(_FakeRolloutServing(close_error=RolloutSessionNotFoundError("s1")))
response = client.post("/v1/realtime/sessions/s1/close")
assert response.status_code == 404
def test_close_route_is_idempotent_for_closed_session():
client = _client(_FakeRolloutServing(close_error=RolloutSessionClosedError("s1")))
response = client.post("/v1/realtime/sessions/s1/close")
assert response.status_code == 200
assert response.json()["closed"] is True
def test_rollout_routes_return_501_when_serving_is_unavailable():
client = _client()
response = client.post("/v1/realtime/sessions/s1/close")
assert response.status_code == 501
+157
View File
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for RolloutSessionStore (RFC #3747, P0)."""
import asyncio
import pytest
from vllm_omni.entrypoints.openai.rollout_session import (
RolloutSession,
RolloutSessionClosedError,
RolloutSessionNotFoundError,
RolloutSessionStepError,
RolloutSessionStore,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture
def store() -> RolloutSessionStore:
return RolloutSessionStore()
@pytest.mark.asyncio
async def test_create_returns_session(store):
s = await store.create("s1", model="dreamzero", mode="world_model_env")
assert isinstance(s, RolloutSession)
assert s.session_id == "s1"
assert s.committed_step_id == -1
assert s.context_length == 0
assert not s.closed
@pytest.mark.asyncio
async def test_get_existing(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
s = await store.get("s1")
assert s.session_id == "s1"
@pytest.mark.asyncio
async def test_get_missing_raises(store):
with pytest.raises(RolloutSessionNotFoundError):
await store.get("does-not-exist")
@pytest.mark.asyncio
async def test_get_closed_raises(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.close("s1")
with pytest.raises(RolloutSessionClosedError):
await store.get("s1")
@pytest.mark.asyncio
async def test_close_missing_raises(store):
with pytest.raises(RolloutSessionNotFoundError):
await store.close("does-not-exist")
@pytest.mark.asyncio
async def test_close_is_idempotent(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.close("s1")
await store.close("s1")
s = await store.get("s1", include_closed=True)
assert s.closed
@pytest.mark.asyncio
async def test_advance_updates_committed_step_id(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.advance("s1", step_id=0)
s = await store.get("s1")
assert s.committed_step_id == 0
assert s.context_length == 1
await store.advance("s1", step_id=1)
s = await store.get("s1")
assert s.committed_step_id == 1
assert s.context_length == 2
@pytest.mark.asyncio
async def test_advance_rejects_duplicate_step_id(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.advance("s1", step_id=0)
with pytest.raises(RolloutSessionStepError):
await store.advance("s1", step_id=0)
s = await store.get("s1")
assert s.committed_step_id == 0
assert s.context_length == 1
@pytest.mark.asyncio
async def test_advance_rejects_out_of_order_step_id(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
with pytest.raises(RolloutSessionStepError):
await store.advance("s1", step_id=2)
s = await store.get("s1")
assert s.committed_step_id == -1
assert s.context_length == 0
@pytest.mark.asyncio
async def test_reset_clears_state(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.advance("s1", step_id=0)
await store.advance("s1", step_id=1)
await store.advance("s1", step_id=2)
await store.advance("s1", step_id=3)
await store.reset("s1")
s = await store.get("s1")
assert s.committed_step_id == -1
assert s.context_length == 0
@pytest.mark.asyncio
async def test_reset_closed_raises(store):
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.close("s1")
with pytest.raises(RolloutSessionClosedError):
await store.reset("s1")
@pytest.mark.asyncio
async def test_failed_step_does_not_advance(store):
"""committed_step_id must not change when a step raises (RFC section 6.5 invariant)."""
await store.create("s1", model="dreamzero", mode="world_model_env")
await store.advance("s1", step_id=0)
# Simulate a failed step: advance is NOT called
s = await store.get("s1")
assert s.committed_step_id == 0 # unchanged
@pytest.mark.asyncio
async def test_concurrent_steps_are_serialised(store):
"""Per-session lock must prevent concurrent context mutations."""
await store.create("s1", model="dreamzero", mode="world_model_env")
s = await store.get("s1")
results: list[int] = []
async def slow_advance(step_id: int, delay: float) -> None:
await asyncio.sleep(delay)
async with s.lock:
await asyncio.sleep(0.01)
await store.advance("s1", step_id=step_id)
results.append(step_id)
await asyncio.gather(slow_advance(0, 0.0), slow_advance(1, 0.01))
# Both steps ran under the per-session lock and committed in order.
assert sorted(results) == [0, 1]
+371
View File
@@ -0,0 +1,371 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for ServingRLRollout (RFC #3747, P0).
The DreamZero engine is mocked. Tests verify session lifecycle, step_id
semantics, committed_step_id invariants, and error paths without a GPU.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import numpy as np
import pytest
from vllm_omni.entrypoints.openai.protocol.rollout import (
Action,
CreateSessionRequest,
Observation,
RolloutStepRequest,
)
from vllm_omni.entrypoints.openai.serving_rl_rollout import ServingRLRollout
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_fake_result(video: Any = None, *, formatter_shaped: bool = True) -> MagicMock:
result = MagicMock()
if video is None:
video = np.zeros((1, 3, 64, 64), dtype=np.float32)
if formatter_shaped:
# Matches output_formatter: video is peeled into images; mm only has actions.
result.images = video
result.multimodal_output = {"actions": np.zeros((1, 8), dtype=np.float32)}
else:
result.images = None
result.multimodal_output = {"video": video}
return result
def _make_openpi(video: Any = None, raises: Exception | None = None, *, formatter_shaped: bool = True):
openpi = MagicMock()
openpi.engine_client = MagicMock()
openpi.model_name = None
async def _generate(*_, **__):
if raises:
raise raises
yield _make_fake_result(video, formatter_shaped=formatter_shaped)
openpi.engine_client.generate = _generate
openpi.build_request = MagicMock(
return_value=MagicMock(
prompt="",
prompts=[""],
request_id="test-req",
sampling_params=MagicMock(),
)
)
return openpi
@pytest.fixture
def serving() -> ServingRLRollout:
return ServingRLRollout(_make_openpi())
# ---------------------------------------------------------------------------
# Session lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_session(serving):
resp = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
assert resp.session_id
assert resp.mode == "world_model_env"
@pytest.mark.asyncio
async def test_get_status_after_create(serving):
resp = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
status = await serving.get_status(resp.session_id)
assert status.committed_step_id == -1
assert status.context_length == 0
assert not status.closed
@pytest.mark.asyncio
async def test_close_session(serving):
resp = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
await serving.close_session(resp.session_id)
status = await serving.get_status(resp.session_id)
assert status.closed
assert status.committed_step_id == -1
serving._openpi.drop_session.assert_any_call(resp.session_id)
serving._openpi.drop_session.assert_any_call(f"{resp.session_id}:stateless")
# ---------------------------------------------------------------------------
# Step - success path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_successful_step_advances_committed_step_id(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(
step_id=0,
observation=Observation(state=[0.1, 0.2]),
action=Action(joint_positions=[0.0] * 7),
)
resp = await serving.step(sess.session_id, req)
assert resp.error is None
assert resp.model_metadata.committed_step_id == 0
status = await serving.get_status(sess.session_id)
assert status.committed_step_id == 0
assert status.context_length == 1
@pytest.mark.asyncio
async def test_repeated_steps_advance_monotonically(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
for i in range(3):
req = RolloutStepRequest(
step_id=i,
observation=Observation(state=[float(i)]),
action=Action(joint_positions=[0.0] * 7),
)
resp = await serving.step(sess.session_id, req)
assert resp.error is None
assert resp.model_metadata.committed_step_id == i
status = await serving.get_status(sess.session_id)
assert status.committed_step_id == 2
assert status.context_length == 3
@pytest.mark.asyncio
async def test_next_observation_present_on_success(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(
step_id=0,
observation=Observation(),
action=Action(),
)
resp = await serving.step(sess.session_id, req)
assert resp.next_observation is not None
assert "video_latent" in resp.next_observation
assert resp.next_observation["dtype"] == "float32"
@pytest.mark.asyncio
async def test_duplicate_step_does_not_rerun_or_advance(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(step_id=0, observation=Observation(), action=Action())
assert (await serving.step(sess.session_id, req)).error is None
resp = await serving.step(sess.session_id, req)
assert resp.error is not None
assert resp.error.code == "step_already_committed"
assert resp.model_metadata.committed_step_id == 0
assert resp.model_metadata.context_length == 1
serving._openpi.build_request.assert_called_once()
@pytest.mark.asyncio
async def test_out_of_order_step_does_not_rerun_or_advance(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(step_id=2, observation=Observation(), action=Action())
resp = await serving.step(sess.session_id, req)
assert resp.error is not None
assert resp.error.code == "step_out_of_order"
assert resp.model_metadata.committed_step_id == -1
assert resp.model_metadata.context_length == 0
serving._openpi.build_request.assert_not_called()
@pytest.mark.asyncio
async def test_stateless_step_does_not_commit_context(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(
step_id=7,
observation=Observation(),
action=Action(),
use_session_context=False,
)
resp = await serving.step(sess.session_id, req)
assert resp.error is None
assert resp.model_metadata.committed_step_id == -1
assert resp.model_metadata.context_length == 0
serving._openpi.build_request.assert_called_once()
kwargs = serving._openpi.build_request.call_args.kwargs
assert kwargs["reset"] is True
assert kwargs["session_id"] == f"{sess.session_id}:stateless"
# ---------------------------------------------------------------------------
# Step - failure path: committed_step_id must NOT advance
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_failed_step_does_not_advance_committed_step_id():
serving = ServingRLRollout(_make_openpi(raises=RuntimeError("engine dead")))
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(step_id=0, observation=Observation(), action=Action())
resp = await serving.step(sess.session_id, req)
assert resp.error is not None
assert resp.error.code == "inference_error"
assert resp.model_metadata.committed_step_id == -1 # must not have advanced
status = await serving.get_status(sess.session_id)
assert status.committed_step_id == -1
@pytest.mark.asyncio
async def test_output_encoding_failure_does_not_advance_committed_step_id():
class BadVideo:
def detach(self):
return self
def float(self):
return self
def cpu(self):
return self
def numpy(self):
raise TypeError("cannot encode")
serving = ServingRLRollout(_make_openpi(video=BadVideo()))
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
req = RolloutStepRequest(step_id=0, observation=Observation(), action=Action())
resp = await serving.step(sess.session_id, req)
assert resp.error is not None
assert resp.error.code == "inference_error"
assert resp.model_metadata.committed_step_id == -1
status = await serving.get_status(sess.session_id)
assert status.committed_step_id == -1
@pytest.mark.asyncio
async def test_failed_step_after_success_preserves_last_committed():
good_serving = ServingRLRollout(_make_openpi())
sess = await good_serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
# Step 0 succeeds
await good_serving.step(sess.session_id, RolloutStepRequest(step_id=0, observation=Observation(), action=Action()))
# Swap engine to failing one - reuse the same store via internal reference
good_serving._openpi = _make_openpi(raises=RuntimeError("oops"))
resp = await good_serving.step(
sess.session_id, RolloutStepRequest(step_id=1, observation=Observation(), action=Action())
)
assert resp.error is not None
assert resp.model_metadata.committed_step_id == 0 # still at last good step
assert resp.model_metadata.context_length == 1
# ---------------------------------------------------------------------------
# Step - unknown session
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_step_unknown_session_returns_error(serving):
req = RolloutStepRequest(step_id=0, observation=Observation(), action=Action())
resp = await serving.step("no-such-session", req)
assert resp.error is not None
assert resp.error.code == "session_not_found"
# ---------------------------------------------------------------------------
# Reset
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reset_clears_committed_step_id(serving):
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
await serving.step(sess.session_id, RolloutStepRequest(step_id=0, observation=Observation(), action=Action()))
await serving.reset_session(sess.session_id)
status = await serving.get_status(sess.session_id)
assert status.committed_step_id == -1
assert status.context_length == 0
serving._openpi.drop_session.assert_called_with(sess.session_id)
# ---------------------------------------------------------------------------
# action -> obs folding (module assumption)
# ---------------------------------------------------------------------------
def test_merge_action_into_obs_emits_dreamzero_state_keys():
from vllm_omni.entrypoints.openai.serving_rl_rollout import _merge_action_into_obs
obs = Observation(state=[9.0] * 8, prompt="test")
action = Action(joint_positions=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], gripper_position=0.5)
result = _merge_action_into_obs(obs, action)
assert result["prompt"] == "test"
np.testing.assert_array_almost_equal(result["observation/joint_position"], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
np.testing.assert_array_almost_equal(result["observation/gripper_position"], [0.5])
def test_merge_action_none_splits_obs_state_for_dreamzero():
from vllm_omni.entrypoints.openai.serving_rl_rollout import _merge_action_into_obs
obs = Observation(state=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 0.25])
result = _merge_action_into_obs(obs, None)
np.testing.assert_array_almost_equal(result["observation/joint_position"], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
np.testing.assert_array_almost_equal(result["observation/gripper_position"], [0.25])
def test_merge_no_state_no_action_omits_state_key():
from vllm_omni.entrypoints.openai.serving_rl_rollout import _merge_action_into_obs
obs = Observation()
result = _merge_action_into_obs(obs, None)
assert "observation/joint_position" not in result
assert "observation/gripper_position" not in result
def test_merge_rejects_wrong_dreamzero_joint_dimension():
from vllm_omni.entrypoints.openai.serving_rl_rollout import _merge_action_into_obs
with pytest.raises(ValueError, match="joint_positions must have 7 values"):
_merge_action_into_obs(Observation(), Action(joint_positions=[1.0, 2.0]))
@pytest.mark.asyncio
async def test_step_reads_formatter_images_not_multimodal_video():
video = np.ones((1, 3, 8, 8), dtype=np.float32)
serving = ServingRLRollout(_make_openpi(video=video, formatter_shaped=True))
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
resp = await serving.step(
sess.session_id,
RolloutStepRequest(step_id=0, observation=Observation(), action=Action()),
)
assert resp.error is None
assert resp.next_observation is not None
assert "video_latent" in resp.next_observation
assert resp.next_observation["shape"] == [1, 3, 8, 8]
@pytest.mark.asyncio
async def test_step_falls_back_to_multimodal_video():
video = np.ones((2, 4, 4), dtype=np.float32)
serving = ServingRLRollout(_make_openpi(video=video, formatter_shaped=False))
sess = await serving.create_session(CreateSessionRequest(model="dreamzero", mode="world_model_env"))
resp = await serving.step(
sess.session_id,
RolloutStepRequest(step_id=0, observation=Observation(), action=Action()),
)
assert resp.error is None
assert resp.next_observation is not None
assert resp.next_observation["shape"] == [2, 4, 4]
@@ -138,6 +138,10 @@ from vllm_omni.entrypoints.openai.protocol.images import (
ImageGenerationResponse,
ResponseFormat,
)
from vllm_omni.entrypoints.openai.protocol.rollout import (
CreateSessionRequest,
RolloutStepRequest,
)
from vllm_omni.entrypoints.openai.protocol.videos import (
VideoDeleteResponse,
VideoGenerationRequest,
@@ -146,8 +150,14 @@ from vllm_omni.entrypoints.openai.protocol.videos import (
VideoResponse,
)
from vllm_omni.entrypoints.openai.realtime_connection import RealtimeConnection
from vllm_omni.entrypoints.openai.rollout_session import (
RolloutSessionCapacityError,
RolloutSessionClosedError,
RolloutSessionNotFoundError,
)
from vllm_omni.entrypoints.openai.serving_audio_generate import OmniOpenAIServingAudioGenerate
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_rl_rollout import ServingRLRollout
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
from vllm_omni.entrypoints.openai.serving_video import (
@@ -604,6 +614,10 @@ async def omni_init_app_state(
engine_client=engine_client,
model_name=model_name,
)
if state.openai_serving_realtime_robot is not None:
state.rl_rollout_serving = ServingRLRollout(state.openai_serving_realtime_robot)
else:
state.rl_rollout_serving = None
state.enable_server_load_tracking = getattr(args, "enable_server_load_tracking", False)
state.server_load_metrics = 0
@@ -949,6 +963,7 @@ async def omni_init_app_state(
stage_configs=state.stage_configs,
)
state.openai_serving_realtime_robot = None
state.rl_rollout_serving = None
state.enable_server_load_tracking = args.enable_server_load_tracking
state.server_load_metrics = 0
@@ -1529,6 +1544,78 @@ async def duplex_websocket(websocket: WebSocket):
await handler.handle_session(websocket)
# RL Rollout serving (RFC #3747, P0)
def _rl_rollout_serving(request: Request) -> ServingRLRollout:
serving = getattr(request.app.state, "rl_rollout_serving", None)
if serving is None:
raise HTTPException(status_code=501, detail="RL rollout serving not available for this model.")
return serving
@router.post("/v1/realtime/sessions")
async def create_rollout_session(body: CreateSessionRequest, request: Request):
serving = _rl_rollout_serving(request)
try:
return (await serving.create_session(body)).model_dump()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RolloutSessionCapacityError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.post("/v1/realtime/sessions/{session_id}/step")
async def rollout_step(session_id: str, body: RolloutStepRequest, request: Request):
serving = _rl_rollout_serving(request)
response = await serving.step(session_id, body)
if response.error is not None:
status_code = None
if response.error.code == "session_not_found":
status_code = 404
elif response.error.code == "session_closed":
status_code = 410
elif response.error.code in {"invalid_request", "step_already_committed", "step_out_of_order"}:
status_code = 400
if status_code is not None:
return JSONResponse(content=response.model_dump(), status_code=status_code)
return response.model_dump()
@router.post("/v1/realtime/sessions/{session_id}/reset")
async def reset_rollout_session(session_id: str, request: Request):
serving = _rl_rollout_serving(request)
try:
return (await serving.reset_session(session_id)).model_dump()
except RolloutSessionNotFoundError:
raise HTTPException(status_code=404, detail=f"Session {session_id!r} not found.")
except RolloutSessionClosedError:
raise HTTPException(status_code=410, detail=f"Session {session_id!r} is closed.")
@router.post("/v1/realtime/sessions/{session_id}/close")
async def close_rollout_session(session_id: str, request: Request):
serving = _rl_rollout_serving(request)
try:
await serving.close_session(session_id)
return {"session_id": session_id, "closed": True}
except RolloutSessionNotFoundError:
raise HTTPException(status_code=404, detail=f"Session {session_id!r} not found.")
except RolloutSessionClosedError:
return {"session_id": session_id, "closed": True}
@router.get("/v1/realtime/sessions/{session_id}/status")
async def rollout_session_status(session_id: str, request: Request):
serving = _rl_rollout_serving(request)
try:
return (await serving.get_status(session_id)).model_dump()
except RolloutSessionNotFoundError:
raise HTTPException(status_code=404, detail=f"Session {session_id!r} not found.")
except RolloutSessionClosedError:
raise HTTPException(status_code=410, detail=f"Session {session_id!r} is closed.")
# Health and Model endpoints for diffusion mode
@@ -0,0 +1,111 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Pydantic schemas for RL rollout serving (RFC #3747).
P0 scope: world_model_env mode only (observation + action -> next observation).
P0 maps rollout observations/actions onto DreamZero's DROID robot_obs schema:
camera images retain their DreamZero observation/* keys, joint/gripper state is
passed through observation/joint_position and observation/gripper_position, and
the returned video payload is the DreamZero VAE latent unless a later endpoint
adds explicit latent decoding.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class Observation(BaseModel):
"""Single-step robot observation."""
images: dict[str, list] | None = Field(
default=None,
description="Named DreamZero camera images as nested numeric lists. Base64 strings are not accepted in P0.",
)
state: list[float] | None = Field(
default=None,
description="Proprioceptive state vector (joint positions, velocities, etc.).",
)
prompt: str = Field(default="", description="Optional language conditioning.")
extra: dict[str, Any] = Field(
default_factory=dict,
description="Pass-through fields forwarded verbatim to robot_obs.",
)
class Action(BaseModel):
"""Executed action for world_model_env conditioning."""
joint_positions: list[float] | None = Field(
default=None,
description="Executed joint positions; forwarded as observation/joint_position for DreamZero.",
)
gripper_position: float | list[float] | None = Field(
default=None,
description="Executed gripper position; forwarded as observation/gripper_position for DreamZero.",
)
extra: dict[str, Any] = Field(default_factory=dict)
class SessionMetadata(BaseModel):
latency_ms: float
steps_generated: int
context_length: int
committed_step_id: int = Field(
description="Highest step_id whose context has been atomically committed. "
"-1 means no step has been committed yet (fresh or reset session).",
)
session_memory_bytes: int | None = None
uncertainty: float | None = None
class ErrorObject(BaseModel):
code: str
message: str
step_id: int | None = None
committed_step_id: int = -1
class CreateSessionRequest(BaseModel):
model: str
mode: Literal["world_model_env"] = "world_model_env"
class CreateSessionResponse(BaseModel):
session_id: str
mode: str
created_at: float
class RolloutStepRequest(BaseModel):
step_id: int = Field(ge=0, description="Monotonically increasing per session.")
observation: Observation
action: Action = Field(
description="Required for world_model_env: executed action that produced this observation.",
)
use_session_context: bool = True
class RolloutStepResponse(BaseModel):
step_id: int
next_observation: dict[str, Any] | None = Field(
default=None,
description="Predicted next observation metadata. P0 returns DreamZero video_latent, not RGB frames.",
)
model_metadata: SessionMetadata
error: ErrorObject | None = None
class ResetSessionResponse(BaseModel):
session_id: str
committed_step_id: int
class SessionStatusResponse(BaseModel):
session_id: str
committed_step_id: int
context_length: int
closed: bool
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""In-memory session store for RL rollout serving (RFC #3747, P0).
Each RolloutSession holds a per-session asyncio.Lock so that concurrent step
requests for the same session are serialised. committed_step_id advances only
on successful step completion; failed or timed-out steps leave it unchanged,
preserving the atomic-context-commit guarantee from section 6.5 of the RFC.
"""
from __future__ import annotations
import asyncio
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Literal
class RolloutSessionNotFoundError(KeyError):
pass
class RolloutSessionClosedError(RuntimeError):
pass
class RolloutSessionStepError(ValueError):
pass
class RolloutSessionCapacityError(RuntimeError):
pass
@dataclass
class RolloutSession:
session_id: str
model: str
mode: Literal["world_model_env"]
created_at: float = field(default_factory=time.time)
committed_step_id: int = -1
context_length: int = 0
closed: bool = False
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class RolloutSessionStore:
"""Thread-safe (asyncio) store for active rollout sessions."""
def __init__(self, max_sessions: int = 64) -> None:
self._sessions: OrderedDict[str, RolloutSession] = OrderedDict()
self._max_sessions = max_sessions
self._store_lock = asyncio.Lock()
async def create(self, session_id: str, model: str, mode: Literal["world_model_env"]) -> RolloutSession:
async with self._store_lock:
self._evict_closed_sessions_for_create()
if len(self._sessions) >= self._max_sessions:
raise RolloutSessionCapacityError(f"Maximum active rollout sessions reached: {self._max_sessions}.")
session = RolloutSession(
session_id=session_id,
model=model,
mode=mode,
)
self._sessions[session_id] = session
self._sessions.move_to_end(session_id)
return session
async def get(self, session_id: str, *, include_closed: bool = False) -> RolloutSession:
session = self._sessions.get(session_id)
if session is None:
raise RolloutSessionNotFoundError(session_id)
self._sessions.move_to_end(session_id)
if session.closed and not include_closed:
raise RolloutSessionClosedError(session_id)
return session
async def reset(self, session_id: str) -> RolloutSession:
session = await self.get(session_id)
async with session.lock:
session.committed_step_id = -1
session.context_length = 0
return session
async def close(self, session_id: str) -> None:
session = await self.get(session_id, include_closed=True)
async with session.lock:
session.closed = True
async def advance(self, session_id: str, step_id: int) -> None:
"""Commit a successfully completed step. Called only on success."""
session = await self.get(session_id)
expected_step_id = session.committed_step_id + 1
if step_id != expected_step_id:
raise RolloutSessionStepError(f"Expected step_id {expected_step_id}, got {step_id}.")
session.committed_step_id = step_id
session.context_length += 1
def _evict_closed_sessions_for_create(self) -> None:
while len(self._sessions) >= self._max_sessions:
for session_id, session in self._sessions.items():
if session.closed:
del self._sessions[session_id]
break
else:
break
@@ -0,0 +1,370 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Serving layer for RL rollout (RFC #3747, P0).
P0 scope: world_model_env mode, single-session, DreamZero backbone.
P0 maps client rollout input onto DreamZero's DROID robot_obs schema and returns
DreamZero video latents explicitly. RGB/frame decoding can be added as a later
contract without pretending the latent is already an image observation.
"""
from __future__ import annotations
import binascii
import time
from collections import abc
from typing import Any
import numpy as np
from vllm.logger import init_logger
from vllm.utils import random_uuid
from vllm_omni.entrypoints.openai.protocol.rollout import (
Action,
CreateSessionRequest,
CreateSessionResponse,
ErrorObject,
Observation,
ResetSessionResponse,
RolloutStepRequest,
RolloutStepResponse,
SessionMetadata,
SessionStatusResponse,
)
from vllm_omni.entrypoints.openai.rollout_session import (
RolloutSession,
RolloutSessionClosedError,
RolloutSessionNotFoundError,
RolloutSessionStore,
)
from vllm_omni.entrypoints.openpi.serving import ServingRealtimeRobotOpenPI
logger = init_logger(__name__)
def _merge_action_into_obs(obs: Observation, action: Action | None) -> dict[str, Any]:
"""Build DreamZero-compatible robot_obs."""
robot_obs: dict[str, Any] = {"prompt": obs.prompt}
if obs.images:
robot_obs.update(obs.images)
if obs.extra:
for key, value in obs.extra.items():
if key.startswith("observation/") or key in {"seed", "embodiment_name"}:
robot_obs[key] = value
joint_positions = action.joint_positions if action is not None else None
gripper_position = action.gripper_position if action is not None else None
if joint_positions is None and obs.state:
joint_positions = obs.state[:7]
if len(obs.state) > 7 and gripper_position is None:
gripper_position = obs.state[7:8]
if joint_positions is not None:
joint_arr = np.asarray(joint_positions, dtype=np.float64).flatten()
if joint_arr.size != 7:
raise ValueError(f"DreamZero DROID joint_positions must have 7 values, got {joint_arr.size}.")
robot_obs["observation/joint_position"] = joint_arr
if gripper_position is not None:
gripper_arr = np.asarray(gripper_position, dtype=np.float64).flatten()
if gripper_arr.size != 1:
raise ValueError(f"DreamZero DROID gripper_position must have 1 value, got {gripper_arr.size}.")
robot_obs["observation/gripper_position"] = gripper_arr
return robot_obs
def _encode_video_output(video: Any) -> dict[str, Any]:
"""Encode DreamZero video latent tensor / ndarray to JSON metadata."""
if video is None:
return {}
if hasattr(video, "detach") and callable(video.detach):
video = video.detach()
if hasattr(video, "float") and callable(video.float):
video = video.float()
if hasattr(video, "cpu") and callable(video.cpu):
video = video.cpu()
if hasattr(video, "numpy"):
video = video.numpy()
if isinstance(video, np.ndarray):
return {
"video_latent": binascii.b2a_base64(video.tobytes(), newline=False).decode(),
"shape": list(video.shape),
"dtype": str(video.dtype),
"encoding": "base64_raw_tensor",
}
return {"video_latent": str(video), "encoding": "string"}
class ServingRLRollout:
"""HTTP serving layer for RL rollout sessions.
Wraps ServingRealtimeRobotOpenPI for world_model_env mode.
Session state and committed_step_id tracking live in RolloutSessionStore.
"""
def __init__(self, openpi_serving: ServingRealtimeRobotOpenPI) -> None:
self._openpi = openpi_serving
self._store = RolloutSessionStore()
# ------------------------------------------------------------------ #
# Session lifecycle #
# ------------------------------------------------------------------ #
async def create_session(self, req: CreateSessionRequest) -> CreateSessionResponse:
self._validate_model(req.model)
session_id = random_uuid()
session = await self._store.create(
session_id=session_id,
model=req.model,
mode=req.mode,
)
logger.info("Created rollout session %s mode=%s", session_id, req.mode)
return CreateSessionResponse(
session_id=session_id,
mode=session.mode,
created_at=session.created_at,
)
async def reset_session(self, session_id: str) -> ResetSessionResponse:
session = await self._store.reset(session_id)
# Next infer still passes reset=True; drop GPU KV now if the engine
# session is reachable from this process.
self._drop_engine_session(session_id)
logger.info("Reset rollout session %s", session_id)
return ResetSessionResponse(
session_id=session_id,
committed_step_id=session.committed_step_id,
)
async def close_session(self, session_id: str) -> None:
await self._store.close(session_id)
self._drop_engine_session(session_id)
self._drop_engine_session(f"{session_id}:stateless")
logger.info("Closed rollout session %s", session_id)
async def get_status(self, session_id: str) -> SessionStatusResponse:
session = await self._store.get(session_id, include_closed=True)
return SessionStatusResponse(
session_id=session_id,
committed_step_id=session.committed_step_id,
context_length=session.context_length,
closed=session.closed,
)
# ------------------------------------------------------------------ #
# Step #
# ------------------------------------------------------------------ #
async def step(
self,
session_id: str,
req: RolloutStepRequest,
) -> RolloutStepResponse:
try:
session = await self._store.get(session_id)
except RolloutSessionNotFoundError:
return self._error_response(
req.step_id,
-1,
0,
"session_not_found",
f"Session {session_id!r} does not exist.",
)
except RolloutSessionClosedError:
return self._error_response(
req.step_id,
-1,
0,
"session_closed",
f"Session {session_id!r} is closed.",
)
async with session.lock:
return await self._run_step(session, req)
async def _run_step(
self,
session: RolloutSession,
req: RolloutStepRequest,
) -> RolloutStepResponse:
t0 = time.perf_counter()
committed = session.committed_step_id
if req.use_session_context:
expected_step_id = committed + 1
if req.step_id != expected_step_id:
if req.step_id <= committed:
code = "step_already_committed"
message = f"Step {req.step_id} is already committed; highest committed step is {committed}."
else:
code = "step_out_of_order"
message = f"Expected step_id {expected_step_id}, got {req.step_id}."
return self._error_response(
req.step_id,
committed,
session.context_length,
code,
message,
)
# reset=True on first call after session create/reset
reset = committed == -1 or not req.use_session_context
engine_session_id = session.session_id
if not req.use_session_context:
engine_session_id = f"{session.session_id}:stateless"
try:
robot_obs = _merge_action_into_obs(req.observation, req.action)
video_out = await self._infer_world_model(
obs=robot_obs,
session_id=engine_session_id,
reset=reset,
)
next_observation = _encode_video_output(video_out)
except ValueError as exc:
logger.info("Invalid rollout step %d for session %s: %s", req.step_id, session.session_id, exc)
return self._error_response(
req.step_id,
committed,
session.context_length,
"invalid_request",
str(exc),
)
except Exception as exc:
logger.exception("Step %d failed for session %s", req.step_id, session.session_id)
# Do NOT advance committed_step_id on failure (RFC section 6.5).
return self._error_response(
req.step_id,
committed,
session.context_length,
"inference_error",
str(exc),
)
if req.use_session_context:
# Only advance committed_step_id on successful contextual steps.
await self._store.advance(session.session_id, req.step_id)
committed = req.step_id
latency_ms = (time.perf_counter() - t0) * 1000.0
metadata = SessionMetadata(
latency_ms=round(latency_ms, 2),
steps_generated=1,
context_length=session.context_length,
committed_step_id=committed,
)
return RolloutStepResponse(
step_id=req.step_id,
next_observation=next_observation,
model_metadata=metadata,
)
async def _infer_world_model(
self,
obs: dict[str, Any],
*,
session_id: str,
reset: bool,
) -> Any:
"""Call the engine and return the video (next-observation) output.
Reuses ServingRealtimeRobotOpenPI.build_request() so request routing,
session_id threading, and OmniDiffusionSamplingParams construction are
identical to the policy_inference path. DreamZero's formatter peels
``video`` into ``result.images`` and leaves only ``actions`` on
``multimodal_output``; read images first and fall back to the raw key.
"""
request = self._openpi.build_request(obs, session_id=session_id, reset=reset)
result = None
async for output in self._openpi.engine_client.generate(
prompt=self._request_prompt(request),
request_id=request.request_id,
sampling_params_list=[request.sampling_params],
):
result = output
if result is None:
raise RuntimeError("World model request produced no output.")
return self._extract_video_output(result)
@staticmethod
def _request_prompt(request: Any) -> Any:
prompt = getattr(request, "prompt", None)
if prompt is not None:
return prompt
return request.prompts[0]
@staticmethod
def _extract_video_output(result: Any) -> Any:
"""Prefer formatter-placed ``images``; fall back to multimodal video."""
images = getattr(result, "images", None)
if isinstance(images, (list, tuple)):
if images:
return images[0]
elif images is not None and not isinstance(images, abc.Mapping):
# Formatter assigns the peeled video tensor directly to images.
return images
multimodal_output = getattr(result, "multimodal_output", None)
if isinstance(multimodal_output, abc.Mapping):
video = multimodal_output.get("video")
if video is not None:
return video
raise RuntimeError(
"No video on result.images or multimodal_output['video']. "
"Confirm DreamZero formatter placement for world_model_env."
)
def _drop_engine_session(self, session_id: str) -> None:
drop = getattr(self._openpi, "drop_session", None)
if not callable(drop):
return
try:
drop(session_id)
except Exception:
logger.exception("Failed to drop engine session %s", session_id)
# ------------------------------------------------------------------ #
# Helpers #
# ------------------------------------------------------------------ #
def _validate_model(self, requested_model: str) -> None:
served_model = getattr(self._openpi, "model_name", None)
if served_model is None:
return
if requested_model == served_model:
return
if requested_model.lower() == "dreamzero" and "dreamzero" in served_model.lower():
return
raise ValueError(f"Requested rollout model {requested_model!r} does not match served model {served_model!r}.")
@staticmethod
def _error_response(
step_id: int,
committed_step_id: int,
context_length: int,
code: str,
message: str,
) -> RolloutStepResponse:
metadata = SessionMetadata(
latency_ms=0.0,
steps_generated=0,
context_length=context_length,
committed_step_id=committed_step_id,
)
return RolloutStepResponse(
step_id=step_id,
next_observation=None,
model_metadata=metadata,
error=ErrorObject(
code=code,
message=message,
step_id=step_id,
committed_step_id=committed_step_id,
),
)
+27 -1
View File
@@ -119,10 +119,32 @@ class ServingRealtimeRobotOpenPI:
def reset(self, obs: dict) -> None:
"""Compatibility hook; per-connection state lives in RobotRealtimeConnection."""
def drop_session(self, session_id: str) -> None:
"""Best-effort release of model-side session state for a closed rollout."""
drop = getattr(self.engine_client, "drop_session", None)
if callable(drop):
drop(session_id)
return
pipeline = self._pipeline()
for name in ("close_ar_diffusion_session", "drop_session_state"):
close = getattr(pipeline, name, None)
if callable(close):
close(session_id)
return
def _pipeline(self) -> Any:
engine = self.engine_client
for attr in ("model_runner", "runner", "diffusion_model_runner"):
runner = getattr(engine, attr, None)
pipeline = getattr(runner, "pipeline", None) if runner is not None else None
if pipeline is not None:
return pipeline
return getattr(engine, "pipeline", None)
async def infer(self, obs: dict, *, session_id: str, reset: bool) -> ActionOutput:
"""raw obs → engine → actions."""
# Build request, run inference through AsyncOmni
request = self._build_request(obs, session_id=session_id, reset=reset)
request = self.build_request(obs, session_id=session_id, reset=reset)
result = None
# OpenPI policy serving is one request -> one action reply. AsyncOmni
# exposes an async iterator, so consume it to completion and use the
@@ -141,6 +163,10 @@ class ServingRealtimeRobotOpenPI:
def _next_request_id(self, session_id: str) -> str:
return f"robot-{session_id}-{next(self._request_counter)}"
def build_request(self, obs: dict, *, session_id: str, reset: bool) -> Any:
"""Build an engine request from raw robot obs."""
return self._build_request(obs, session_id=session_id, reset=reset)
def _build_request(self, obs: dict, *, session_id: str, reset: bool) -> Any:
"""Build engine request from raw robot obs.