Files
ai-video-fullstack/backend/services/openai_realtime/events.py
T
Xin Wang 2649d64b55
Quality gate / backend (push) Canceled after 0s
Quality gate / frontend (push) Canceled after 0s
feat: harden public OpenAI API contracts
2026-09-07 14:25:48 +08:00

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,
},
}