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,467 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""OpenAI Realtime server events: typed classes and their wire rendering.
|
||||
|
||||
Every event is a frozen dataclass with explicit fields, and the Realtime wire
|
||||
JSON is *derived* from those fields by :meth:`RealtimeEvent.to_realtime`. That
|
||||
rendering is pure --- it never consults session state --- which is why it can
|
||||
live here, away from whoever owns the session. The stateful part (response and
|
||||
item ids, content-part bookkeeping) is consumed when the events are
|
||||
*constructed*, by the consumer.
|
||||
|
||||
This module holds the 30 events OpenAI's Realtime API defines. vLLM-Omni's own
|
||||
additions to the wire (listen/speak turn-taking, playback acknowledgement,
|
||||
session resume) are a separate vocabulary in
|
||||
``vllm_omni.protocol.duplex.events``, so a consumer that only speaks GA does
|
||||
not inherit them.
|
||||
|
||||
``session_id`` / ``epoch`` are bound by the consumer when an event leaves it;
|
||||
producers build events with the defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import ClassVar
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
return f"event_{uuid4().hex}"
|
||||
|
||||
|
||||
_IDENTITY_FIELDS = frozenset({"session_id", "epoch", "event_id"})
|
||||
|
||||
|
||||
def wire_value(value: object) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
return {str(k): wire_value(v) for k, v in value.items()}
|
||||
if isinstance(value, tuple | list):
|
||||
return [wire_value(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RealtimeEvent:
|
||||
"""Base of every public session event."""
|
||||
|
||||
#: Realtime ``type`` this class renders to.
|
||||
wire_type: ClassVar[str] = ""
|
||||
#: Fields that are omitted from the wire object when ``None``.
|
||||
optional_wire_fields: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
session_id: str = ""
|
||||
epoch: int | None = None
|
||||
#: Server event id (OpenAI ``event_id``); generated at construction.
|
||||
event_id: str = field(default_factory=new_event_id)
|
||||
|
||||
# ---- identity ----
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self.wire_type
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return False
|
||||
|
||||
# ---- generic accessors (None when the class has no such field) ----
|
||||
|
||||
@property
|
||||
def response_id(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def item_id(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def audio(self) -> bytes | None:
|
||||
return None
|
||||
|
||||
# ---- wire rendering (pure) ----
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
data: dict[str, object] = {}
|
||||
for f in fields(self):
|
||||
if f.name in _IDENTITY_FIELDS:
|
||||
continue
|
||||
value = getattr(self, f.name)
|
||||
if value is None and f.name in self.optional_wire_fields:
|
||||
continue
|
||||
data[f.name] = wire_value(value)
|
||||
return data
|
||||
|
||||
def to_realtime(self) -> dict[str, object]:
|
||||
"""The Realtime wire JSON object for this event (derived from the fields)."""
|
||||
return {"type": self.wire_type, "event_id": self.event_id, **self._wire_fields()}
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"{type(self).__name__}(type={self.wire_type!r}, session_id={self.session_id!r}, epoch={self.epoch!r})"
|
||||
|
||||
|
||||
# ---- shared bases ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ResponseScopedEvent(RealtimeEvent):
|
||||
"""Events addressed to one response (and usually one output item / content part)."""
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
output_index: int = 0
|
||||
content_index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class _ItemEvent(RealtimeEvent):
|
||||
previous_item_id: str | None = None
|
||||
item: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def item_id(self) -> str | None:
|
||||
value = self.item.get("id")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
# ---- GA server events ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionCreated(RealtimeEvent):
|
||||
wire_type = "session.created"
|
||||
|
||||
session: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionUpdated(RealtimeEvent):
|
||||
wire_type = "session.updated"
|
||||
|
||||
session: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ResponseCreated(RealtimeEvent):
|
||||
wire_type = "response.created"
|
||||
|
||||
response: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OutputItemAdded(RealtimeEvent):
|
||||
wire_type = "response.output_item.added"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
output_index: int = 0
|
||||
item: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def item_id(self) -> str | None:
|
||||
value = self.item.get("id")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OutputItemDone(OutputItemAdded):
|
||||
wire_type = "response.output_item.done"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ContentPartAdded(ResponseScopedEvent):
|
||||
wire_type = "response.content_part.added"
|
||||
|
||||
part: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ContentPartDone(ContentPartAdded):
|
||||
wire_type = "response.content_part.done"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AudioDelta(ResponseScopedEvent):
|
||||
wire_type = "response.output_audio.delta"
|
||||
|
||||
#: Base64 audio in ``format``.
|
||||
delta: str = ""
|
||||
|
||||
@property
|
||||
def audio(self) -> bytes | None:
|
||||
try:
|
||||
return base64.b64decode(self.delta)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AudioDone(ResponseScopedEvent):
|
||||
wire_type = "response.output_audio.done"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TranscriptDelta(ResponseScopedEvent):
|
||||
wire_type = "response.output_audio_transcript.delta"
|
||||
|
||||
delta: str = ""
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.delta
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TranscriptDone(ResponseScopedEvent):
|
||||
wire_type = "response.output_audio_transcript.done"
|
||||
|
||||
transcript: str = ""
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.transcript
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TextDelta(ResponseScopedEvent):
|
||||
wire_type = "response.output_text.delta"
|
||||
|
||||
delta: str = ""
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.delta
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TextDone(ResponseScopedEvent):
|
||||
wire_type = "response.output_text.done"
|
||||
|
||||
text: str = "" # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FunctionCallArgumentsDelta(RealtimeEvent):
|
||||
wire_type = "response.function_call_arguments.delta"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
output_index: int = 0
|
||||
call_id: str = ""
|
||||
delta: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FunctionCallArgumentsDone(RealtimeEvent):
|
||||
wire_type = "response.function_call_arguments.done"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
output_index: int = 0
|
||||
call_id: str = ""
|
||||
arguments: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ResponseDone(RealtimeEvent):
|
||||
wire_type = "response.done"
|
||||
|
||||
response: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def status(self) -> str | None:
|
||||
value = self.response.get("status")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RateLimitsUpdated(RealtimeEvent):
|
||||
wire_type = "rate_limits.updated"
|
||||
|
||||
rate_limits: tuple[Mapping[str, object], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputCommitted(RealtimeEvent):
|
||||
wire_type = "input_audio_buffer.committed"
|
||||
|
||||
previous_item_id: str | None = None
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"previous_item_id": self.previous_item_id, "item_id": self.item_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputCleared(RealtimeEvent):
|
||||
wire_type = "input_audio_buffer.cleared"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SpeechStarted(RealtimeEvent):
|
||||
wire_type = "input_audio_buffer.speech_started"
|
||||
|
||||
audio_start_ms: int = 0
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SpeechStopped(RealtimeEvent):
|
||||
wire_type = "input_audio_buffer.speech_stopped"
|
||||
|
||||
audio_end_ms: int = 0
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemAdded(_ItemEvent):
|
||||
wire_type = "conversation.item.added"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemCreated(_ItemEvent):
|
||||
wire_type = "conversation.item.created"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemDone(_ItemEvent):
|
||||
wire_type = "conversation.item.done"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemRetrieved(RealtimeEvent):
|
||||
wire_type = "conversation.item.retrieved"
|
||||
|
||||
item: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def item_id(self) -> str | None:
|
||||
value = self.item.get("id")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemDeleted(RealtimeEvent):
|
||||
wire_type = "conversation.item.deleted"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"item_id": self.item_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemTruncated(RealtimeEvent):
|
||||
wire_type = "conversation.item.truncated"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
content_index: int = 0
|
||||
audio_end_ms: int = 0
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {
|
||||
"item_id": self.item_id,
|
||||
"content_index": self.content_index,
|
||||
"audio_end_ms": self.audio_end_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputTranscriptionCompleted(RealtimeEvent):
|
||||
wire_type = "conversation.item.input_audio_transcription.completed"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
content_index: int = 0
|
||||
transcript: str = ""
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.transcript
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OutputAudioCleared(RealtimeEvent):
|
||||
wire_type = "output_audio_buffer.cleared"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ErrorEvent(RealtimeEvent):
|
||||
wire_type = "error"
|
||||
|
||||
code: str = "internal_error"
|
||||
message: str = ""
|
||||
#: Client ``event_id`` this error answers (OpenAI ``error.event_id``).
|
||||
related_event_id: str | None = None
|
||||
param: str | None = None
|
||||
|
||||
@property
|
||||
def error_type(self) -> str:
|
||||
"""OpenAI's error class for this error.
|
||||
|
||||
OpenAI standardises only the three classes, not the codes, so the base
|
||||
reports the default. A consumer with its own code vocabulary overrides
|
||||
this --- see ``vllm_omni.protocol.duplex.events.ErrorEvent``.
|
||||
"""
|
||||
return "invalid_request_error"
|
||||
|
||||
@property
|
||||
def error(self) -> dict[str, object]:
|
||||
error: dict[str, object] = {"type": self.error_type, "code": self.code, "message": self.message}
|
||||
if self.related_event_id:
|
||||
error["event_id"] = self.related_event_id
|
||||
if self.param:
|
||||
error["param"] = self.param
|
||||
return error
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.message
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"error": self.error}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AudioDelta",
|
||||
"AudioDone",
|
||||
"ContentPartAdded",
|
||||
"ContentPartDone",
|
||||
"ErrorEvent",
|
||||
"FunctionCallArgumentsDelta",
|
||||
"FunctionCallArgumentsDone",
|
||||
"InputCleared",
|
||||
"InputCommitted",
|
||||
"InputTranscriptionCompleted",
|
||||
"ItemAdded",
|
||||
"ItemCreated",
|
||||
"ItemDeleted",
|
||||
"ItemDone",
|
||||
"ItemRetrieved",
|
||||
"ItemTruncated",
|
||||
"OutputAudioCleared",
|
||||
"OutputItemAdded",
|
||||
"OutputItemDone",
|
||||
"RateLimitsUpdated",
|
||||
"RealtimeEvent",
|
||||
"ResponseCreated",
|
||||
"ResponseDone",
|
||||
"ResponseScopedEvent",
|
||||
"SessionCreated",
|
||||
"SessionUpdated",
|
||||
"SpeechStarted",
|
||||
"SpeechStopped",
|
||||
"TextDelta",
|
||||
"TextDone",
|
||||
"TranscriptDelta",
|
||||
"TranscriptDone",
|
||||
"new_event_id",
|
||||
"wire_value",
|
||||
]
|
||||
Reference in new issue
Block a user