Files
vllm-omni/vllm_omni/entrypoints/openpi/connection.py
T
MaciejBalaNVandHongsheng Liu 05ab509269 Cosmos3 action policy improvements (#6460)
Signed-off-by: Maciej Bala <mbala@nvidia.com>
Signed-off-by: MaciejBalaNV <mbala@nvidia.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-10 22:32:50 +08:00

295 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""WebSocket connection for robot policy inference (OpenPI protocol).
Protocol (compatible with OpenPI policy clients):
Connect -> server sends msgpack(PolicyServerConfig fields)
Infer -> client sends msgpack(obs), server sends msgpack(ndarray)
Reset -> client sends msgpack({endpoint:reset}), server sends msgpack(status)
NumPy values use msgpack-numpy marker mappings. Outbound payloads follow the
openpi-client format so official OpenPI clients can decode action responses:
ndarray -> {__ndarray__: true, dtype, shape, data}
scalar -> {__npgeneric__: true, dtype, data}
Inbound payloads accept both the openpi-client markers above and the legacy
vLLM-native markers:
ndarray -> {nd: true, type, kind, shape, data}
scalar -> {nd: false, type, kind, data}
`kind` is required on the `nd` markers because it is what separates them from a
plain user mapping, but its value is dialect-specific: the vLLM-native markers
carry the dtype kind character while the `msgpack-numpy` package leaves it empty
for plain dtypes and sets "V" for structured ones. Both spellings are accepted.
Scalars packed by `msgpack-numpy` omit `kind` entirely and are therefore left as
mappings.
"""
from __future__ import annotations
import asyncio
from collections import OrderedDict
from typing import Any
import msgspec
import numpy as np
from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect
from vllm.logger import init_logger
from vllm_omni.entrypoints.openpi.serving import (
ServingRealtimeRobotOpenPI,
)
logger = init_logger(__name__)
_DEFAULT_IDLE_TIMEOUT = 30.0
MAX_OPENPI_PAYLOAD_BYTES = 64 * 1024 * 1024
_MISSING = object()
# Upper bound on the per-connection set of seen session ids.
MAX_TRACKED_SESSIONS = 1024
def _pack_numpy(obj: Any) -> Any:
if isinstance(obj, np.ndarray | np.generic) and obj.dtype.kind in ("V", "O", "c"):
raise ValueError(f"Unsupported dtype: {obj.dtype}")
if isinstance(obj, np.ndarray):
if not obj.flags.c_contiguous:
obj = np.ascontiguousarray(obj)
return {
b"__ndarray__": True,
b"data": obj.tobytes(),
b"dtype": obj.dtype.str,
b"shape": obj.shape,
}
if isinstance(obj, np.generic):
return {
b"__npgeneric__": True,
b"data": obj.item(),
b"dtype": obj.dtype.str,
}
raise TypeError(f"Unsupported type: {type(obj)!r}")
def _mapping_get(obj: dict[Any, Any], key: str, default: Any = None) -> Any:
return obj.get(key, obj.get(key.encode(), default))
def _decode_marker_text(value: Any) -> str:
if isinstance(value, bytes):
return value.decode()
return str(value)
def _is_truthy_marker(value: Any) -> bool:
return value is True or value == 1
def _decode_vllm_numpy_marker(obj: dict[Any, Any]) -> Any:
nd = _mapping_get(obj, "nd", _MISSING)
dtype = _mapping_get(obj, "type", _MISSING)
kind = _mapping_get(obj, "kind", _MISSING)
data = _mapping_get(obj, "data", _MISSING)
if nd is _MISSING or dtype is _MISSING or kind is _MISSING or data is _MISSING:
return _MISSING
kind_text = _decode_marker_text(kind)
# Structured markers carry a dtype descriptor list in `type`, so reject them
# before `np.dtype()` sees something it cannot parse.
if kind_text == "V":
raise ValueError("Unsupported dtype: structured arrays")
dtype_obj = np.dtype(_decode_marker_text(dtype))
if kind_text not in ("", dtype_obj.kind):
raise ValueError(f"NumPy dtype marker kind mismatch: {dtype_obj.kind!r} != {kind_text!r}")
if dtype_obj.kind in ("V", "O", "c"):
raise ValueError(f"Unsupported dtype: {dtype_obj}")
array = np.frombuffer(data, dtype=dtype_obj).copy()
if nd:
shape = _mapping_get(obj, "shape", _MISSING)
if shape is _MISSING:
raise ValueError("NumPy ndarray marker is missing shape")
return array.reshape(tuple(shape))
return array[0]
def _decode_openpi_numpy_marker(obj: dict[Any, Any]) -> Any:
if _is_truthy_marker(_mapping_get(obj, "__ndarray__", False)):
data = _mapping_get(obj, "data", _MISSING)
dtype = _mapping_get(obj, "dtype", _MISSING)
shape = _mapping_get(obj, "shape", _MISSING)
if data is _MISSING or dtype is _MISSING or shape is _MISSING:
raise ValueError("OpenPI ndarray marker is missing required fields")
dtype_obj = np.dtype(_decode_marker_text(dtype))
if dtype_obj.kind in ("V", "O", "c"):
raise ValueError(f"Unsupported dtype: {dtype_obj}")
array = np.frombuffer(data, dtype=dtype_obj).copy()
return array.reshape(tuple(shape))
if _is_truthy_marker(_mapping_get(obj, "__npgeneric__", False)):
data = _mapping_get(obj, "data", _MISSING)
dtype = _mapping_get(obj, "dtype", _MISSING)
if data is _MISSING or dtype is _MISSING:
raise ValueError("OpenPI scalar marker is missing required fields")
dtype_obj = np.dtype(_decode_marker_text(dtype))
if dtype_obj.kind in ("V", "O", "c"):
raise ValueError(f"Unsupported dtype: {dtype_obj}")
return dtype_obj.type(data)
return _MISSING
def _unpack_numpy(obj: Any) -> Any:
if isinstance(obj, dict):
for decoder in (_decode_vllm_numpy_marker, _decode_openpi_numpy_marker):
decoded = decoder(obj)
if decoded is not _MISSING:
return decoded
return {key: _unpack_numpy(value) for key, value in obj.items()}
if isinstance(obj, list):
return [_unpack_numpy(value) for value in obj]
return obj
def _pack(obj: Any) -> bytes:
return msgspec.msgpack.encode(obj, enc_hook=_pack_numpy)
def _unpack(data: bytes) -> Any:
return _unpack_numpy(msgspec.msgpack.decode(data))
class RobotRealtimeConnection:
"""WebSocket connection for robot policy inference."""
def __init__(
self,
websocket: WebSocket,
serving: ServingRealtimeRobotOpenPI,
idle_timeout: float | None = _DEFAULT_IDLE_TIMEOUT,
) -> None:
self.websocket = websocket
self.serving = serving
self._idle_timeout = idle_timeout
self._current_session_id: str | None = None
# Session ids seen on this connection, most-recently-used last.
self._seen_sessions: OrderedDict[str, None] = OrderedDict()
def reset(self) -> None:
self._current_session_id = None
self._seen_sessions.clear()
def _mark_session_seen(self, session_id: str) -> bool:
"""Record ``session_id``; return True when it is the first sighting.
A session's first observation is the one that carries ``reset`` to the
policy, so this needs per-session memory.
"""
if session_id in self._seen_sessions:
self._seen_sessions.move_to_end(session_id)
return False
self._seen_sessions[session_id] = None
if len(self._seen_sessions) > MAX_TRACKED_SESSIONS:
evicted, _ = self._seen_sessions.popitem(last=False)
logger.debug(
"Robot OpenPI connection is tracking more than %d sessions; dropped least-recently-used %s",
MAX_TRACKED_SESSIONS,
evicted,
)
return True
async def _send_error(self, message: str) -> None:
await self.websocket.send_bytes(_pack({"type": "error", "message": message}))
def _unpack_request(self, data: bytes) -> dict[str, Any]:
if len(data) > MAX_OPENPI_PAYLOAD_BYTES:
raise ValueError("OpenPI request payload too large")
obs = _unpack(data)
if not isinstance(obs, dict):
raise ValueError("Invalid request payload")
return obs
async def handle_connection(self) -> None:
"""Main loop for OpenPI-compatible policy serving."""
await self.websocket.accept()
try:
# Send model-specific PolicyServerConfig resolved by serving from
# diffusion od_config.model_config.
metadata = self.serving.policy_server_config.to_dict()
await self.websocket.send_bytes(_pack(metadata))
while True:
idle_timeout = self._idle_timeout
if idle_timeout is None:
msg = await self.websocket.receive()
else:
try:
msg = await asyncio.wait_for(
self.websocket.receive(),
timeout=idle_timeout,
)
except asyncio.TimeoutError:
logger.info("Robot OpenPI connection idle timeout after %.1f seconds", idle_timeout)
try:
await self.websocket.close()
except Exception:
logger.debug("Failed to close idle robot OpenPI websocket", exc_info=True)
return
if msg.get("type") == "websocket.disconnect":
break
if "bytes" not in msg or not msg["bytes"]:
continue
try:
obs = self._unpack_request(msg["bytes"])
except Exception:
logger.exception("Invalid robot OpenPI request payload")
try:
await self._send_error("Invalid request payload")
except Exception:
break
continue
try:
endpoint = obs.pop("endpoint", "infer")
if endpoint == "reset":
self.reset()
self.serving.reset(obs)
await self.websocket.send_bytes(_pack({"status": "reset successful"}))
else:
session_id = str(obs.get("session_id") or self._current_session_id or "default")
if session_id != self._current_session_id:
if self._current_session_id is not None:
logger.info(
"Robot OpenPI session changed %s -> %s",
self._current_session_id,
session_id,
)
self._current_session_id = session_id
reset = self._mark_session_seen(session_id)
actions = await self.serving.infer(
obs,
session_id=session_id,
reset=reset,
)
await self.websocket.send_bytes(_pack(actions))
except Exception:
logger.exception("Error handling request")
try:
await self._send_error("Internal inference error")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
logger.exception("Connection error")