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
@@ -1,677 +1,79 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Typed events emitted by a duplex session.
|
||||
"""The event vocabulary one duplex session speaks, assembled from two layers.
|
||||
|
||||
Every public event is a frozen dataclass with explicit fields. The Realtime
|
||||
wire JSON is *derived* from those fields by :meth:`DuplexEvent.to_realtime`,
|
||||
which is pure: it never consults session state. The stateful part (response /
|
||||
item ids, content-part bookkeeping) lives in
|
||||
``vllm_omni.engine.duplex.realtime_events`` on the session runner and is
|
||||
consumed when the events are *constructed*.
|
||||
The classes themselves no longer live here. ``vllm_omni.protocol.duplex.events``
|
||||
carries the whole vocabulary --- 22 Tier 1 classes re-exported from
|
||||
``vllm_omni.protocol.realtime.events``, 8 Tier 2 ones and 12 Tier 3 ones --- so
|
||||
a non-duplex Realtime consumer can take the Tier 1 half and its wire rendering
|
||||
without the duplex session control plane (RFC #6592 P0a). The engine imports
|
||||
the duplex module and never the Tier 1 one directly.
|
||||
|
||||
``session_id`` / ``epoch`` are bound by
|
||||
``DuplexSessionManager.emit`` when an event leaves the runner; producers
|
||||
(projector, serving handshake helpers) build events with the defaults.
|
||||
What is genuinely engine-side stays: :data:`DOMAIN_TERMINAL_EVENTS` and
|
||||
:data:`MODEL_OUTPUT_EVENTS` are the session runner's epoch-filter policy, not
|
||||
wire contract --- they name *internal* event types, and which of them may never
|
||||
be dropped as stale is a runtime decision.
|
||||
|
||||
``DuplexEvent`` is re-exported as the name the engine and the clients already
|
||||
use. It is an alias of
|
||||
:class:`~vllm_omni.protocol.realtime.events.RealtimeEvent`, not a subclass,
|
||||
because an event has no engine-internal half --- what the session emits is
|
||||
exactly what the socket sends. A *command* does have one (``payload()`` renders
|
||||
the runner's mailbox), which is why ``DuplexCommand`` is a real class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import ClassVar, cast
|
||||
from uuid import uuid4
|
||||
from vllm_omni.protocol.duplex.errors import REALTIME_ERROR_TYPES_BY_CODE
|
||||
from vllm_omni.protocol.duplex.events import (
|
||||
AudioDelta,
|
||||
AudioDone,
|
||||
ContentPartAdded,
|
||||
ContentPartDone,
|
||||
DuplexEvent,
|
||||
DuplexRawEvent,
|
||||
ErrorEvent,
|
||||
FunctionCallArgumentsDelta,
|
||||
FunctionCallArgumentsDone,
|
||||
InputCleared,
|
||||
InputCommitted,
|
||||
InputTranscriptionCompleted,
|
||||
ItemAdded,
|
||||
ItemCreated,
|
||||
ItemDeleted,
|
||||
ItemDone,
|
||||
ItemRetrieved,
|
||||
ItemTruncated,
|
||||
Listen,
|
||||
OutputAudioCleared,
|
||||
OutputItemAdded,
|
||||
OutputItemDone,
|
||||
OverlapDecision,
|
||||
PlaybackAcknowledged,
|
||||
RateLimitsUpdated,
|
||||
ResponseCreated,
|
||||
ResponseDone,
|
||||
SessionClosed,
|
||||
SessionCreated,
|
||||
SessionExpired,
|
||||
SessionHeartbeatAck,
|
||||
SessionReplaced,
|
||||
SessionResumed,
|
||||
SessionResyncRequired,
|
||||
SessionUpdated,
|
||||
Speak,
|
||||
SpeechStarted,
|
||||
SpeechStopped,
|
||||
TextDelta,
|
||||
TextDone,
|
||||
TranscriptDelta,
|
||||
TranscriptDone,
|
||||
TurnEvent,
|
||||
error_event,
|
||||
new_event_id,
|
||||
)
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
return f"event_{uuid4().hex}"
|
||||
|
||||
|
||||
#: 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",
|
||||
}
|
||||
|
||||
_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 DuplexEvent:
|
||||
"""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})"
|
||||
|
||||
|
||||
# ---- session lifecycle ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionCreated(DuplexEvent):
|
||||
wire_type = "session.created"
|
||||
optional_wire_fields = frozenset({"attachment_generation", "resume_token"})
|
||||
|
||||
session: Mapping[str, object] = field(default_factory=dict)
|
||||
#: Transport resume credentials (set by the websocket handler when resume is supported).
|
||||
attachment_generation: int | None = None
|
||||
resume_token: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionUpdated(DuplexEvent):
|
||||
wire_type = "session.updated"
|
||||
|
||||
session: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@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, **DuplexEvent._wire_fields(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionReplaced(DuplexEvent):
|
||||
"""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, **DuplexEvent._wire_fields(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionResyncRequired(DuplexEvent):
|
||||
"""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, **DuplexEvent._wire_fields(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionClosed(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
"""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}
|
||||
|
||||
|
||||
# ---- response lifecycle ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class _ResponseEvent(DuplexEvent):
|
||||
"""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 ResponseCreated(DuplexEvent):
|
||||
wire_type = "response.created"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
response: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Listen(DuplexEvent):
|
||||
"""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(_ResponseEvent):
|
||||
wire_type = "response.speak"
|
||||
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OverlapDecision(DuplexEvent):
|
||||
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 OutputItemAdded(DuplexEvent):
|
||||
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(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
wire_type = "response.output_audio.delta"
|
||||
optional_wire_fields = frozenset({"sample_rate_hz", "metadata"})
|
||||
|
||||
#: Base64 audio in ``format``.
|
||||
delta: str = ""
|
||||
format: str = "pcm16"
|
||||
sample_rate_hz: int | None = None
|
||||
metadata: Mapping[str, object] | None = None
|
||||
|
||||
@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(_ResponseEvent):
|
||||
wire_type = "response.output_audio.done"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TranscriptDelta(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
wire_type = "response.output_text.done"
|
||||
|
||||
text: str = "" # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FunctionCallArgumentsDelta(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
wire_type = "response.done"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
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(DuplexEvent):
|
||||
wire_type = "rate_limits.updated"
|
||||
|
||||
rate_limits: tuple[Mapping[str, object], ...] = ()
|
||||
|
||||
|
||||
# ---- input buffer / conversation ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputCommitted(DuplexEvent):
|
||||
wire_type = "input_audio_buffer.committed"
|
||||
|
||||
previous_item_id: str | None = None
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
#: The session-internal ``input.committed`` event.
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {
|
||||
"previous_item_id": self.previous_item_id,
|
||||
"item_id": self.item_id,
|
||||
"event": _wire_value(self.details),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputCleared(DuplexEvent):
|
||||
wire_type = "input_audio_buffer.cleared"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SpeechStarted(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
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 _ItemEvent(DuplexEvent):
|
||||
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
|
||||
|
||||
|
||||
@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(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
wire_type = "conversation.item.deleted"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"item_id": self.item_id, "event": _wire_value(self.details)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemTruncated(DuplexEvent):
|
||||
wire_type = "conversation.item.truncated"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
content_index: int = 0
|
||||
audio_end_ms: int = 0
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
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,
|
||||
"event": _wire_value(self.details),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputTranscriptionCompleted(DuplexEvent):
|
||||
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
|
||||
|
||||
|
||||
# ---- playback / control ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OutputAudioCleared(DuplexEvent):
|
||||
wire_type = "output_audio_buffer.cleared"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaybackAcknowledged(DuplexEvent):
|
||||
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 ErrorEvent(DuplexEvent):
|
||||
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
|
||||
#: 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]:
|
||||
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
|
||||
error.update(cast("Mapping[str, object]", _wire_value(self.extra)))
|
||||
return error
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.message
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"error": self.error}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class DuplexRawEvent(DuplexEvent):
|
||||
"""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 {}),
|
||||
)
|
||||
|
||||
|
||||
#: Internal events that terminate a response/session and must never be dropped as stale.
|
||||
DOMAIN_TERMINAL_EVENTS = frozenset(
|
||||
{
|
||||
"response.done",
|
||||
@@ -682,7 +84,6 @@ DOMAIN_TERMINAL_EVENTS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
#: Internal streaming model-output events subject to epoch/close stale filtering.
|
||||
MODEL_OUTPUT_EVENTS = frozenset(
|
||||
{
|
||||
"response.created",
|
||||
@@ -705,13 +106,11 @@ MODEL_OUTPUT_EVENTS = frozenset(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN_TERMINAL_EVENTS",
|
||||
"MODEL_OUTPUT_EVENTS",
|
||||
"REALTIME_ERROR_TYPES_BY_CODE",
|
||||
"AudioDelta",
|
||||
"AudioDone",
|
||||
"ContentPartAdded",
|
||||
"ContentPartDone",
|
||||
"DOMAIN_TERMINAL_EVENTS",
|
||||
"DuplexEvent",
|
||||
"DuplexRawEvent",
|
||||
"ErrorEvent",
|
||||
@@ -727,11 +126,13 @@ __all__ = [
|
||||
"ItemRetrieved",
|
||||
"ItemTruncated",
|
||||
"Listen",
|
||||
"MODEL_OUTPUT_EVENTS",
|
||||
"OutputAudioCleared",
|
||||
"OutputItemAdded",
|
||||
"OutputItemDone",
|
||||
"OverlapDecision",
|
||||
"PlaybackAcknowledged",
|
||||
"REALTIME_ERROR_TYPES_BY_CODE",
|
||||
"RateLimitsUpdated",
|
||||
"ResponseCreated",
|
||||
"ResponseDone",
|
||||
|
||||
Reference in new issue
Block a user