[Core] Give the OpenAI Realtime wire codec a reusable home (RFC #6592 P0a) (#7640)

Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
psv666 authored and GitHub committed 2026-09-17 02:33:40 -07:00
1 parent 825ab20b07
commit e3be42e052
37 files changed
+3792 -1629

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""vLLM-Omni's full-duplex extension of the OpenAI Realtime wire protocol.
OpenAI's Realtime API assumes one turn at a time: the client commits input, the
server answers. A full-duplex session does not --- the model listens while it
speaks, it decides when to take a turn, the client reports playback progress,
and a dropped socket re-attaches to a session that kept running. Those need
events and commands the GA vocabulary does not have.
This package is that delta. It builds on ``vllm_omni.protocol.realtime`` (same
base classes, same pure wire rendering, no duplication) and, like it, owns no
session and imports no runtime: the duplex engine depends on this package, not
the other way round.
The one door
------------
It is also the *only* door a duplex consumer uses. ``protocol.duplex.commands``
and ``protocol.duplex.events`` carry the complete vocabulary --- Tier 1 classes
re-exported unchanged alongside the Tier 2 and Tier 3 ones --- and this module
re-exports the Tier 1 helper functions. So the layering is a chain, not a mesh::
protocol.realtime (OpenAI's surface)
^
| imports
|
protocol.duplex (this package: re-exports Tier 1, adds Tier 2 and 3)
^
| imports
|
duplex engine / entrypoints / clients
``tests/protocol/duplex/test_duplex_protocol_facade.py`` asserts that the last
arrow is the only one: no duplex consumer imports ``protocol.realtime``
directly. The payoff is that a helper which later needs a duplex-specific
version --- ``convert_input_audio_with_rate`` is the standing example, it
resamples to MiniCPM-o's 16 kHz rather than the client's rate --- is overridden
in one file instead of at every call site.
What stays outside
------------------
The *mailbox* half of a command does not live here. A duplex command carries
two different things: the client event it decodes from (wire, this package) and
the dictionary the session runner consumes (engine). They genuinely differ ---
``session.update``, ``conversation.item.create`` / ``.delete`` / ``.truncate``
all travel on the runner's ``turn.signal`` channel --- so ``payload()`` and its
``type`` stay in ``vllm_omni.engine.duplex.commands``. Events have no such
half, which is why :data:`~vllm_omni.protocol.duplex.events.DuplexEvent` can be
a plain alias of ``RealtimeEvent`` while ``DuplexCommand`` cannot.
"""
from vllm_omni.protocol.duplex.errors import (
REALTIME_ERROR_TYPES_BY_CODE,
RealtimeProtocolError,
realtime_error_type,
)
from vllm_omni.protocol.realtime.audio import (
MAX_INPUT_SAMPLE_RATE_HZ,
MIN_INPUT_SAMPLE_RATE_HZ,
convert_input_audio_with_rate,
convert_output_audio,
decode_g711_alaw,
decode_g711_ulaw,
encode_float32_mono_wav_base64,
encode_g711_alaw,
encode_g711_ulaw,
resample_pcm16_mono,
validate_input_sample_rate_hz,
wav_payload_to_pcm16,
)
from vllm_omni.protocol.realtime.audio_input import (
REALTIME_INPUT_HINT_KEYS,
RealtimeAudioAppend,
copy_realtime_input_hints,
decode_audio_append,
input_explicitly_non_speech,
input_looks_like_speech,
)
from vllm_omni.protocol.realtime.capabilities import (
RealtimeProtocolCapabilities,
RealtimeSessionRejection,
validate_session_payload,
)
from vllm_omni.protocol.realtime.formats import (
REALTIME_INPUT_AUDIO_FORMATS,
REALTIME_OUTPUT_AUDIO_FORMATS,
is_supported_realtime_input_format,
parse_realtime_audio_format,
realtime_audio_format_object,
realtime_output_format,
validate_conversation_item_audio_formats,
validate_realtime_response_audio_formats,
validate_realtime_session_audio_formats,
)
from vllm_omni.protocol.realtime.items import (
input_transcript_from_item,
normalize_conversation_item,
text_chars_for_audio_ms_from_marks,
truncate_realtime_item_content,
validate_realtime_item_truncate,
validate_realtime_video_frames,
)
from vllm_omni.protocol.realtime.session import (
RealtimeInputDefaults,
apply_realtime_session_defaults,
input_audio_transcription_config,
json_safe_realtime_payload,
realtime_max_output_tokens,
realtime_overlap_fields,
)
__all__ = [
"MAX_INPUT_SAMPLE_RATE_HZ",
"MIN_INPUT_SAMPLE_RATE_HZ",
"REALTIME_ERROR_TYPES_BY_CODE",
"REALTIME_INPUT_AUDIO_FORMATS",
"REALTIME_INPUT_HINT_KEYS",
"REALTIME_OUTPUT_AUDIO_FORMATS",
"RealtimeAudioAppend",
"RealtimeInputDefaults",
"RealtimeProtocolCapabilities",
"RealtimeProtocolError",
"RealtimeSessionRejection",
"apply_realtime_session_defaults",
"convert_input_audio_with_rate",
"convert_output_audio",
"copy_realtime_input_hints",
"decode_audio_append",
"decode_g711_alaw",
"decode_g711_ulaw",
"encode_float32_mono_wav_base64",
"encode_g711_alaw",
"encode_g711_ulaw",
"input_audio_transcription_config",
"input_explicitly_non_speech",
"input_looks_like_speech",
"input_transcript_from_item",
"is_supported_realtime_input_format",
"json_safe_realtime_payload",
"normalize_conversation_item",
"parse_realtime_audio_format",
"realtime_audio_format_object",
"realtime_error_type",
"realtime_max_output_tokens",
"realtime_output_format",
"realtime_overlap_fields",
"resample_pcm16_mono",
"text_chars_for_audio_ms_from_marks",
"truncate_realtime_item_content",
"validate_conversation_item_audio_formats",
"validate_input_sample_rate_hz",
"validate_realtime_item_truncate",
"validate_realtime_response_audio_formats",
"validate_realtime_session_audio_formats",
"validate_realtime_video_frames",
"validate_session_payload",
"wav_payload_to_pcm16",
]
+164
View File
@@ -0,0 +1,164 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The client-event vocabulary a full-duplex session accepts.
Seven of the seventeen are events OpenAI's Realtime API has no equivalent for,
because they only make sense when the model and the user can talk at the same
time: the user cuts in (``barge_in``), the client reports how much audio it
played (``playback.ack``), a turn transition is signalled locally
(``turn.signal``), input is abandoned without committing (``input.cancel``),
text is pushed into a live turn (``input.text.append``), and the session is
kept alive or closed explicitly (``session.heartbeat`` / ``session.close``).
Two more are OpenAI commands carrying duplex-only fields (Tier 2).
The remaining eight are OpenAI's, unchanged, and re-exported from
``vllm_omni.protocol.realtime.commands`` rather than redeclared --- they are
the same objects. They are re-exported so this module is the *whole*
vocabulary and a duplex consumer never has to import the Tier 1 package
directly; keeping them declared there means a GA-only consumer is still not
handed events its clients never send.
None of these carries the engine's mailbox rendering: ``payload()`` and its
channel live in ``vllm_omni.engine.duplex.commands``, because for four of the
seventeen the runner's channel is not the client event type at all.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import ClassVar
from vllm_omni.protocol.realtime import commands as realtime_commands
from vllm_omni.protocol.realtime.commands import (
CancelResponse,
ClearInput,
ClearOutputAudio,
CreateItem,
CreateResponse,
DeleteItem,
RealtimeCommand,
TruncateItem,
UpdateSession,
)
# ---- Tier 2: OpenAI names carrying vLLM-Omni extensions ----
@dataclass(frozen=True, slots=True, kw_only=True)
class AppendAudio(realtime_commands.AppendAudio):
"""Tier 2: OpenAI's ``input_audio_buffer.append`` plus the duplex hints.
OpenAI's append is just base64 audio. The duplex lane also lets a client
declare this chunk's own format and rate, whether it believes the chunk is
speech, camera frames captured alongside it, and its timing --- all
additive, all ignored by a stock client.
"""
#: Model-neutral hints carried through from the wire (rms, vad, transcript hints ...).
format: str = "pcm16"
sample_rate_hz: int | None = None
is_speech: bool | None = None
video_frames: tuple[str, ...] = ()
duration_ms: int | None = None
audio_end_ms: int | None = None
hints: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class Commit(realtime_commands.Commit):
"""Tier 2: OpenAI's ``input_audio_buffer.commit`` plus duplex turn control.
OpenAI's commit carries nothing and always produces an item. Here a commit
may be non-final, may decline to start a response, and may declare itself
silence --- the semantic divergence documented in
``docs/serving/realtime_duplex_api.md`` ("Commit != response").
"""
#: Realtime conversation item created for this commit (wire correlation only).
#: ``None`` means "no explicit request": the runner decides on commit (auto-response
#: sessions answer on their own); ``True`` / ``False`` force it.
create_response: bool | None = None
is_speech: bool | None = None
realtime_item_id: str | None = None
# ---- Tier 3: vLLM-Omni only ----
@dataclass(frozen=True, slots=True, kw_only=True)
class AppendText(RealtimeCommand):
wire_type: ClassVar[str] = "input.text.append"
text: str
@dataclass(frozen=True, slots=True, kw_only=True)
class CancelInput(RealtimeCommand):
wire_type: ClassVar[str] = "input.cancel"
@dataclass(frozen=True, slots=True, kw_only=True)
class BargeIn(RealtimeCommand):
wire_type: ClassVar[str] = "barge_in"
@dataclass(frozen=True, slots=True, kw_only=True)
class SignalTurn(RealtimeCommand):
"""Generic ``turn.signal`` (local turn transitions such as ``user_started``)."""
wire_type: ClassVar[str] = "turn.signal"
event: str
signal_payload: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class AckPlayback(RealtimeCommand):
wire_type: ClassVar[str] = "playback.ack"
played_ms: int
committed_ms: int | None = None
response_id: str | None = None
item_id: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class Heartbeat(RealtimeCommand):
wire_type: ClassVar[str] = "session.heartbeat"
@dataclass(frozen=True, slots=True, kw_only=True)
class CloseSession(RealtimeCommand):
"""Graceful close requested through the command stream (``session.close``)."""
wire_type: ClassVar[str] = "session.close"
reason: str = "client_close"
#: The complete command vocabulary a duplex client may send: the eight Tier 1
#: classes re-exported unchanged, the two Tier 2 classes defined above, and the
#: seven Tier 3 ones. A duplex consumer imports from here and never reaches
#: past this module into ``vllm_omni.protocol.realtime``, so a command that
#: later grows a duplex extension changes only this file.
__all__ = [
# Tier 1 --- re-exported from ``vllm_omni.protocol.realtime.commands``.
"CancelResponse",
"ClearInput",
"ClearOutputAudio",
"CreateItem",
"CreateResponse",
"DeleteItem",
"RealtimeCommand",
"TruncateItem",
"UpdateSession",
# Tier 2 --- an OpenAI command plus duplex-only fields.
"AppendAudio",
"Commit",
# Tier 3 --- vLLM-Omni only.
"AckPlayback",
"AppendText",
"BargeIn",
"CancelInput",
"CloseSession",
"Heartbeat",
"SignalTurn",
]
+75
View File
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""vLLM-Omni's Realtime error-code vocabulary.
OpenAI's Realtime API standardises the ``error.type`` classes
(``invalid_request_error`` / ``server_error`` / ``rate_limit_error``) but not
the ``code`` values inside them. Ours are listed here --- Tier 3 in
``docs/serving/realtime_duplex_api.md``, "the full error-code vocabulary" ---
so ``protocol/realtime`` stays the OpenAI surface and nothing more.
"""
from __future__ import annotations
from vllm_omni.protocol.realtime.errors import RealtimeProtocolError
__all__ = ["REALTIME_ERROR_TYPES_BY_CODE", "RealtimeProtocolError", "realtime_error_type"]
#: OpenAI Realtime ``error.type`` for each internal error code.
REALTIME_ERROR_TYPES_BY_CODE: dict[str, str] = {
"bad_event": "invalid_request_error",
"bad_audio": "invalid_request_error",
"config_timeout": "invalid_request_error",
"invalid_json": "invalid_request_error",
"event_too_large": "invalid_request_error",
"unknown_event": "invalid_request_error",
"internal_error": "server_error",
"runtime_append_failed": "server_error",
"runtime_append_task_failed": "server_error",
"runtime_signal_failed": "server_error",
"runtime_abort_failed": "server_error",
"runtime_data_plane_stream_failed": "server_error",
"runtime_data_plane_text_without_audio": "server_error",
"resource_exhausted": "rate_limit_error",
"session_exists": "invalid_request_error",
"session_closed": "invalid_request_error",
"unknown_session": "invalid_request_error",
"invalid_duplex_runtime_config": "invalid_request_error",
"instructions_update_unsupported": "invalid_request_error",
"persona_update_unsupported": "invalid_request_error",
"voice_update_unsupported": "invalid_request_error",
"unsupported_nemotron_duplex_mode": "invalid_request_error",
"unsupported_native_response_options": "invalid_request_error",
"runtime_touch_failed": "server_error",
"engine_error": "server_error",
"input_backpressure": "rate_limit_error",
"response_already_active": "invalid_request_error",
"response_not_active": "invalid_request_error",
"response_create_without_input": "invalid_request_error",
"text_only_turn_unsupported": "invalid_request_error",
"commit_aborted": "server_error",
"input_audio_buffer_empty": "invalid_request_error",
"missing_item_id": "invalid_request_error",
"item_not_found": "invalid_request_error",
"playback_item_mismatch": "invalid_request_error",
"playback_item_not_found": "invalid_request_error",
"playback_ack_too_late": "invalid_request_error",
"unsupported_audio_format": "invalid_request_error",
"unsupported_turn_detection": "invalid_request_error",
"unsupported_ref_audio_path": "invalid_request_error",
"ref_audio_required": "invalid_request_error",
"model_update_unsupported": "invalid_request_error",
"voice_update_after_audio_unsupported": "invalid_request_error",
"ref_audio_update_unsupported": "invalid_request_error",
"native_text_append_unsupported": "invalid_request_error",
"invalid_video_frames": "invalid_request_error",
"invalid_function_call_output": "invalid_request_error",
"server_vad_unavailable": "server_error",
}
def realtime_error_type(code: str) -> str:
"""The OpenAI ``error.type`` bucket for an internal error code."""
return REALTIME_ERROR_TYPES_BY_CODE.get(code, "invalid_request_error")
+419
View File
@@ -0,0 +1,419 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""vLLM-Omni's half of the Realtime server-event vocabulary.
``docs/serving/realtime_duplex_api.md`` sorts every message into three tiers.
Tier 1 --- identical to OpenAI --- is ``vllm_omni.protocol.realtime.events``.
The other two are here:
**Tier 2, OpenAI names carrying vLLM-Omni extensions.** Same wire type, same
OpenAI fields, plus additive keys a stock client ignores. Each one subclasses
its Tier 1 twin and declares only what it adds, so the OpenAI surface stays
honest: a consumer that wants pure GA takes ``protocol/realtime`` and gets
exactly OpenAI's fields.
**Tier 3, vLLM-Omni only.** Events OpenAI has no equivalent for, because they
only make sense when the model and the user can talk at the same time: the
model decides when to listen and when to speak, the client reports how much
audio it played, overlapping speech needs a recorded decision, and a dropped
socket can re-attach to a live session.
Same-named classes are deliberate --- ``duplex.events.SessionCreated`` *is*
``realtime.events.SessionCreated`` with our extensions. Import whichever
surface you mean.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import cast
from vllm_omni.protocol.duplex.errors import REALTIME_ERROR_TYPES_BY_CODE
from vllm_omni.protocol.realtime import events as realtime_events
from vllm_omni.protocol.realtime.events import (
AudioDone,
ContentPartAdded,
ContentPartDone,
FunctionCallArgumentsDelta,
FunctionCallArgumentsDone,
InputCleared,
InputTranscriptionCompleted,
ItemAdded,
ItemCreated,
ItemDone,
ItemRetrieved,
OutputAudioCleared,
OutputItemAdded,
OutputItemDone,
RateLimitsUpdated,
RealtimeEvent,
ResponseScopedEvent,
SessionUpdated,
SpeechStarted,
SpeechStopped,
TextDelta,
TextDone,
TranscriptDelta,
TranscriptDone,
new_event_id,
wire_value,
)
#: The duplex event base class *is* the Realtime one: unlike a command, an
#: event has no engine-internal half to carry --- what the session emits is
#: exactly what the socket sends. Named here rather than in the engine so the
#: duplex vocabulary is described in one place, and kept as an alias rather
#: than an empty subclass so ``isinstance`` and the class identity tests in
#: ``tests/protocol/`` stay meaningful.
DuplexEvent = RealtimeEvent
# ---- Tier 2: OpenAI names carrying vLLM-Omni extensions ----
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionCreated(realtime_events.SessionCreated):
"""Tier 2: OpenAI's ``SessionCreated`` plus the resume credentials a reconnecting client needs."""
#: Transport resume credentials (set by the websocket handler when resume is supported).
optional_wire_fields = frozenset({"attachment_generation", "resume_token"})
attachment_generation: int | None = None
resume_token: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class ResponseCreated(realtime_events.ResponseCreated):
"""Tier 2: OpenAI's ``ResponseCreated`` plus a top-level ``response_id``.
OpenAI carries the id nested as ``response.id``; the duplex lane also
reports it at the top level, where every other response-scoped event
already has one.
"""
response_id: str | None = None # type: ignore[assignment]
@dataclass(frozen=True, slots=True, kw_only=True)
class ResponseDone(realtime_events.ResponseDone):
"""Tier 2: OpenAI's ``ResponseDone`` plus a top-level ``response_id``.
OpenAI carries the id nested as ``response.id``; the duplex lane also
reports it at the top level, where every other response-scoped event
already has one.
"""
response_id: str | None = None # type: ignore[assignment]
@dataclass(frozen=True, slots=True, kw_only=True)
class AudioDelta(realtime_events.AudioDelta):
"""Tier 2: OpenAI's ``AudioDelta`` plus the chunk's own format/rate and the duplex playback metadata."""
optional_wire_fields = frozenset({"sample_rate_hz", "metadata"})
format: str = "pcm16"
sample_rate_hz: int | None = None
metadata: Mapping[str, object] | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class InputCommitted(realtime_events.InputCommitted):
"""Tier 2: OpenAI's ``InputCommitted`` plus the native ``input.committed`` event echoed under ``event``."""
#: The session-internal ``input.committed`` event.
details: Mapping[str, object] = field(default_factory=dict)
def _wire_fields(self) -> dict[str, object]:
# Explicit parent call, not ``super()``: ``@dataclass(slots=True)``
# rebuilds the class, so the zero-argument form's ``__class__`` cell
# points at the pre-slots class and is no longer in the MRO.
base = realtime_events.__dict__[type(self).__name__]._wire_fields(self)
return {**base, "event": wire_value(self.details)}
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemDeleted(realtime_events.ItemDeleted):
"""Tier 2: OpenAI's ``ItemDeleted`` plus the originating event echoed under ``event``."""
details: Mapping[str, object] = field(default_factory=dict)
def _wire_fields(self) -> dict[str, object]:
# Explicit parent call, not ``super()``: ``@dataclass(slots=True)``
# rebuilds the class, so the zero-argument form's ``__class__`` cell
# points at the pre-slots class and is no longer in the MRO.
base = realtime_events.__dict__[type(self).__name__]._wire_fields(self)
return {**base, "event": wire_value(self.details)}
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemTruncated(realtime_events.ItemTruncated):
"""Tier 2: OpenAI's ``ItemTruncated`` plus the originating event echoed under ``event``."""
details: Mapping[str, object] = field(default_factory=dict)
def _wire_fields(self) -> dict[str, object]:
# Explicit parent call, not ``super()``: ``@dataclass(slots=True)``
# rebuilds the class, so the zero-argument form's ``__class__`` cell
# points at the pre-slots class and is no longer in the MRO.
base = realtime_events.__dict__[type(self).__name__]._wire_fields(self)
return {**base, "event": wire_value(self.details)}
@dataclass(frozen=True, slots=True, kw_only=True)
class ErrorEvent(realtime_events.ErrorEvent):
"""Tier 2: OpenAI's ``ErrorEvent`` plus vLLM-Omni's error codes and any extra keys merged into the envelope."""
#: Extra keys merged into the wire ``error`` object (``retryable`` ...).
extra: Mapping[str, object] = field(default_factory=dict)
@property
def error_type(self) -> str:
return REALTIME_ERROR_TYPES_BY_CODE.get(self.code, "invalid_request_error")
@property
def error(self) -> dict[str, object]:
# Explicit parent call, not ``super()``: ``@dataclass(slots=True)``
# rebuilds the class, so the zero-argument form's ``__class__`` cell
# points at the pre-slots class and is no longer in the MRO. Reached
# through ``__dict__`` because attribute access on the class gives the
# getter function, not the ``property`` that owns it.
error = dict(realtime_events.ErrorEvent.__dict__["error"].fget(self))
error.update(cast("Mapping[str, object]", wire_value(self.extra)))
return error
# ---- Tier 3: vLLM-Omni only ----
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionResumed(SessionCreated):
wire_type = "session.resumed"
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id, **RealtimeEvent._wire_fields(self)}
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionReplaced(RealtimeEvent):
"""A newer connection took the session over (sent to the replaced socket)."""
wire_type = "session.replaced"
attachment_generation: int = 0
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id, **RealtimeEvent._wire_fields(self)}
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionResyncRequired(RealtimeEvent):
"""The replay journal cannot bridge the gap; the client must start over."""
wire_type = "session.resync_required"
reason: str = "journal_gap"
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id, **RealtimeEvent._wire_fields(self)}
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionClosed(RealtimeEvent):
wire_type = "session.closed"
reason: str = "closed"
#: The session-internal close event (kept for clients that inspect it).
details: Mapping[str, object] = field(default_factory=dict)
@property
def is_terminal(self) -> bool:
return True
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id, "reason": self.reason, "event": wire_value(self.details)}
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionExpired(SessionClosed):
wire_type = "session.expired"
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id, "reason": self.reason}
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionHeartbeatAck(RealtimeEvent):
wire_type = "session.heartbeat_ack"
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id}
@dataclass(frozen=True, slots=True, kw_only=True)
class TurnEvent(RealtimeEvent):
"""Turn-state transition (``turn.event``)."""
wire_type = "turn.event"
event: str = ""
turn_state: str = ""
def _wire_fields(self) -> dict[str, object]:
return {"session_id": self.session_id, "event": self.event, "turn_state": self.turn_state, "epoch": self.epoch}
@dataclass(frozen=True, slots=True, kw_only=True)
class Listen(RealtimeEvent):
"""The model decided to keep listening (no spoken response for this turn)."""
wire_type = "response.listen"
optional_wire_fields = frozenset({"response_id"})
response_id: str | None = None # type: ignore[assignment]
details: Mapping[str, object] = field(default_factory=dict)
def _wire_fields(self) -> dict[str, object]:
response: dict[str, object] = {
"object": "realtime.response",
"status": "listening",
"metadata": wire_value(self.details),
}
data: dict[str, object] = {"session_id": self.session_id, "epoch": self.epoch, "response": response}
if self.response_id:
response["id"] = self.response_id
data["response_id"] = self.response_id
return data
@dataclass(frozen=True, slots=True, kw_only=True)
class Speak(ResponseScopedEvent):
wire_type = "response.speak"
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class OverlapDecision(RealtimeEvent):
wire_type = "overlap.decision"
policy: str | None = None
action: str | None = None
reason: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
def _wire_fields(self) -> dict[str, object]:
return {
"session_id": self.session_id,
"epoch": self.epoch,
"policy": self.policy,
"action": self.action,
"reason": self.reason,
"metadata": wire_value(self.details),
}
@dataclass(frozen=True, slots=True, kw_only=True)
class PlaybackAcknowledged(RealtimeEvent):
wire_type = "playback.acknowledged"
#: The session-internal ``playback.acknowledged`` event (cursor, committed_ms ...).
details: Mapping[str, object] = field(default_factory=dict)
def _wire_fields(self) -> dict[str, object]:
return {"event": wire_value(self.details)}
@dataclass(frozen=True, slots=True, kw_only=True)
class DuplexRawEvent(RealtimeEvent):
"""A session-internal event with no dedicated Realtime type (``duplex.<internal type>``)."""
wire_type = "duplex.raw"
internal_type: str = ""
details: Mapping[str, object] = field(default_factory=dict)
@property
def type(self) -> str:
return f"duplex.{self.internal_type}"
def to_realtime(self) -> dict[str, object]:
return {"type": self.type, "event_id": self.event_id, "event": wire_value(self.details)}
def error_event(
code: str,
message: str,
*,
event_id: object | None = None,
param: object | None = None,
extra: Mapping[str, object] | None = None,
) -> ErrorEvent:
"""Build the error event for an internal code; ``event_id`` is the *client* event id."""
return ErrorEvent(
code=code,
message=message,
related_event_id=event_id if isinstance(event_id, str) and event_id else None,
param=param if isinstance(param, str) and param else None,
extra=dict(extra or {}),
)
#: The complete event vocabulary a duplex client may receive: 22 Tier 1
#: classes re-exported unchanged, the 8 Tier 2 ones defined above, and 12
#: Tier 3 ones. A duplex consumer imports from here and never reaches past
#: this module into ``vllm_omni.protocol.realtime``.
__all__ = [
# Tier 1 --- re-exported from ``vllm_omni.protocol.realtime.events``.
"AudioDone",
"ContentPartAdded",
"ContentPartDone",
"FunctionCallArgumentsDelta",
"FunctionCallArgumentsDone",
"InputCleared",
"InputTranscriptionCompleted",
"ItemAdded",
"ItemCreated",
"ItemDone",
"ItemRetrieved",
"OutputAudioCleared",
"OutputItemAdded",
"OutputItemDone",
"RateLimitsUpdated",
"SessionUpdated",
"SpeechStarted",
"SpeechStopped",
"TextDelta",
"TextDone",
"TranscriptDelta",
"TranscriptDone",
# Tier 2 --- an OpenAI event plus duplex-only fields.
"AudioDelta",
"ErrorEvent",
"InputCommitted",
"ItemDeleted",
"ItemTruncated",
"ResponseCreated",
"ResponseDone",
"SessionCreated",
# Tier 3 --- vLLM-Omni only.
"DuplexRawEvent",
"Listen",
"OverlapDecision",
"PlaybackAcknowledged",
"SessionClosed",
"SessionExpired",
"SessionHeartbeatAck",
"SessionReplaced",
"SessionResumed",
"SessionResyncRequired",
"Speak",
"TurnEvent",
# Bases and helpers shared with the Tier 1 surface.
"DuplexEvent",
"REALTIME_ERROR_TYPES_BY_CODE",
"RealtimeEvent",
"ResponseScopedEvent",
"error_event",
"new_event_id",
"wire_value",
]