Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
1 parent
825ab20b07
commit
e3be42e052
37 files changed
+3792
-1629
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
Reference in new issue
Block a user