133 lines
4.2 KiB
Python
133 lines
4.2 KiB
Python
"""Small, explicit validation helpers for the supported Realtime event subset."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from pydantic import ValidationError
|
|
from services.openai_realtime.schemas import REALTIME_CLIENT_EVENT_ADAPTER
|
|
|
|
|
|
SUPPORTED_CLIENT_EVENTS = {
|
|
"session.update",
|
|
"conversation.item.create",
|
|
"conversation.item.truncate",
|
|
"input_audio_buffer.append",
|
|
"input_audio_buffer.commit",
|
|
"input_audio_buffer.clear",
|
|
"output_audio_buffer.clear",
|
|
"response.create",
|
|
"response.cancel",
|
|
"x.interactive_media.capabilities.update",
|
|
"x.interactive_media.session.variables.update",
|
|
}
|
|
|
|
SUPPORTED_CAPABILITIES = {
|
|
"dynamic_variables",
|
|
"workflow_events",
|
|
"handoff",
|
|
"video_track",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class RealtimeEventError(ValueError):
|
|
message: str
|
|
code: str = "invalid_request_error"
|
|
param: str | None = None
|
|
event_id: str | None = None
|
|
|
|
def __str__(self) -> str:
|
|
return self.message
|
|
|
|
|
|
def require_event(message: object) -> dict[str, Any]:
|
|
if not isinstance(message, dict):
|
|
raise RealtimeEventError("Event must be a JSON object")
|
|
event_type = str(message.get("type") or "")
|
|
if event_type not in SUPPORTED_CLIENT_EVENTS:
|
|
raise RealtimeEventError(
|
|
f"Unsupported client event: {event_type or '<missing>'}",
|
|
code="invalid_event",
|
|
param="type",
|
|
event_id=str(message.get("event_id") or "") or None,
|
|
)
|
|
try:
|
|
validated = REALTIME_CLIENT_EVENT_ADAPTER.validate_python(message)
|
|
except ValidationError as exc:
|
|
first = exc.errors(include_url=False)[0]
|
|
location = list(first.get("loc") or [])
|
|
if location and location[0] == event_type:
|
|
location.pop(0)
|
|
param = ".".join(str(part) for part in location) or None
|
|
raise RealtimeEventError(
|
|
str(first.get("msg") or "Event does not match the schema"),
|
|
code="invalid_event",
|
|
param=param,
|
|
event_id=str(message.get("event_id") or "") or None,
|
|
) from exc
|
|
return validated.model_dump(exclude_none=True)
|
|
|
|
|
|
def assistant_id_from_model(model: object) -> str:
|
|
value = str(model or "").strip()
|
|
if not value.startswith("assistant:") or not value.removeprefix("assistant:"):
|
|
raise RealtimeEventError(
|
|
"model must use assistant:asst_xxx",
|
|
code="invalid_model",
|
|
param="session.model",
|
|
)
|
|
return value.removeprefix("assistant:")
|
|
|
|
|
|
def normalize_turn_detection(value: object) -> dict[str, Any] | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, dict) or value.get("type") != "server_vad":
|
|
raise RealtimeEventError(
|
|
"turn_detection must be server_vad or null",
|
|
param="session.audio.input.turn_detection",
|
|
)
|
|
try:
|
|
threshold = float(value.get("threshold", 0.7))
|
|
prefix_ms = int(value.get("prefix_padding_ms", 200))
|
|
silence_ms = int(value.get("silence_duration_ms", 600))
|
|
except (TypeError, ValueError) as exc:
|
|
raise RealtimeEventError(
|
|
"Invalid server_vad threshold, padding, or silence duration",
|
|
param="session.audio.input.turn_detection",
|
|
) from exc
|
|
if (
|
|
not 0 <= threshold <= 1
|
|
or not 0 <= prefix_ms <= 5000
|
|
or not 100 <= silence_ms <= 10000
|
|
):
|
|
raise RealtimeEventError(
|
|
"Invalid server_vad threshold, padding, or silence duration",
|
|
param="session.audio.input.turn_detection",
|
|
)
|
|
return {
|
|
"type": "server_vad",
|
|
"threshold": threshold,
|
|
"prefix_padding_ms": prefix_ms,
|
|
"silence_duration_ms": silence_ms,
|
|
"create_response": bool(value.get("create_response", True)),
|
|
"interrupt_response": bool(value.get("interrupt_response", True)),
|
|
}
|
|
|
|
|
|
def error_event(error: RealtimeEventError) -> dict[str, Any]:
|
|
return {
|
|
"type": "error",
|
|
"event_id": f"event_{uuid4().hex}",
|
|
"error": {
|
|
"type": "invalid_request_error",
|
|
"code": error.code,
|
|
"message": error.message,
|
|
"param": error.param,
|
|
"event_id": error.event_id,
|
|
},
|
|
}
|