feat(realtime): add Meta Muse Voice transcription

This commit is contained in:
Young Han 2026-09-02 13:22:47 -07:00 committed by ryan-crabbe-berri
parent 19c8553052
commit b82b31a44f
15 changed files with 2446 additions and 57 deletions

View file

@ -19,7 +19,7 @@ from litellm.types.llms.openai import (
OpenAIRealtimeStreamResponseBaseObject,
OpenAIRealtimeStreamSessionEvents,
)
from litellm.types.realtime import ALL_DELTA_TYPES
from litellm.types.realtime import ALL_DELTA_TYPES, RealtimeInputAudioTranscriptionUsage
from .litellm_logging import Logging as LiteLLMLogging
from .realtime_errors import client_close_code, realtime_error_event, websocket_close_reason
@ -116,6 +116,10 @@ class RealtimeEventNormalizer(Protocol):
def patch_outgoing_session(self, session: dict) -> dict: ...
class RealtimeUsageProvider(Protocol):
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None: ...
DefaultLoggedRealTimeEventTypes: Final = [
"session.created",
"response.create",
@ -139,6 +143,8 @@ class RealTimeStreaming:
force_transcription_model: str | None = None,
event_normalizer: RealtimeEventNormalizer | None = None,
logging_worker: _LoggingWorker = GLOBAL_LOGGING_WORKER,
usage_provider: RealtimeUsageProvider | None = None,
exclude_private_content_from_logs: bool = False,
):
self.websocket: _ClientWebSocket = websocket
self.backend_ws = backend_ws
@ -200,6 +206,10 @@ class RealTimeStreaming:
self._is_transcription_session: bool = force_transcription_model is not None
# Optional per-provider GA event normalizer (e.g. XAIRealtimeNormalizer).
self._event_normalizer = event_normalizer
self._usage_provider: RealtimeUsageProvider | None = (
usage_provider if usage_provider is not None else provider_config
)
self._exclude_private_content_from_logs = exclude_private_content_from_logs
# Per-connection caps for pre-setup audio frames (message count + total bytes).
_MAX_BUFFERED_MESSAGES: int = 200
@ -237,7 +247,7 @@ class RealTimeStreaming:
def _should_store_message(
self,
message_obj: dict | OpenAIRealtimeEvents,
message_obj: dict[str, Any] | OpenAIRealtimeEvents, # mutable-ok: existing realtime event contract
) -> bool:
_msg_type: Final = message_obj["type"] if "type" in message_obj else None
if self.logged_real_time_event_types == "*":
@ -246,16 +256,54 @@ class RealTimeStreaming:
return True
return False
def _message_for_logging(
self,
message_obj: dict[str, Any], # mutable-ok: existing realtime event contract
) -> dict[str, Any]: # mutable-ok: logging stores concrete event dictionaries
if not self._exclude_private_content_from_logs:
return message_obj
logged_message: dict[str, Any] = { # mutable-ok: incrementally builds the sanitized event copy
key: message_obj[key]
for key in (
"type",
"event_id",
"item_id",
"response_id",
"conversation_id",
"session_id",
"content_index",
"output_index",
"model",
"mode",
"usage",
)
if key in message_obj
}
session: Final = message_obj.get("session")
if isinstance(session, dict):
logged_session: Final[dict[str, Any]] = { # mutable-ok: sanitized JSON session snapshot
key: session[key] for key in ("id", "model", "mode", "type") if key in session
}
if logged_session:
logged_message["session"] = logged_session
return logged_message
def store_message(self, message: str | bytes | dict | OpenAIRealtimeEvents):
"""Store message in list"""
if isinstance(message, bytes):
message = message.decode("utf-8")
if isinstance(message, dict):
# TypedDict union members do not narrow to plain dict for mypy.
message_obj: dict[str, Any] = cast(dict[str, Any], message)
parsed_message_obj: dict[str, Any] = cast( # cast-ok: TypedDict events are JSON dictionaries
dict[str, Any], message
)
else:
message_obj = cast(dict[str, Any], json.loads(cast(str, message)))
self._collect_tool_calls_from_response_done(cast(dict, message_obj))
parsed_message_obj = cast( # cast-ok: parsed realtime events are JSON dictionaries
dict[str, Any], json.loads(message)
)
if not self._exclude_private_content_from_logs:
self._collect_tool_calls_from_response_done(parsed_message_obj)
message_obj: Final = self._message_for_logging(parsed_message_obj)
if not self._should_store_message(message_obj):
return
try:
@ -273,6 +321,8 @@ class RealTimeStreaming:
def _collect_user_input_from_client_event(self, message: str | dict) -> None:
"""Extract user text content from client WebSocket events for spend logging."""
if self._exclude_private_content_from_logs:
return
try:
if isinstance(message, str):
msg_obj = json.loads(message)
@ -309,6 +359,8 @@ class RealTimeStreaming:
def _collect_user_input_from_backend_event(self, event_obj: dict | OpenAIRealtimeEvents) -> None:
"""Extract user voice transcription from backend events for spend logging."""
if self._exclude_private_content_from_logs:
return
try:
event_type: Final = event_obj.get("type", "")
if event_type == "conversation.item.input_audio_transcription.completed":
@ -364,9 +416,9 @@ class RealTimeStreaming:
pass
def _flush_unbilled_transcription_usage(self) -> None:
if self.provider_config is None:
if self._usage_provider is None:
return
usage: Final = self.provider_config.unbilled_usage_on_session_close(self.model)
usage: Final = self._usage_provider.unbilled_usage_on_session_close(self.model)
if usage is None:
return
flush_event: Final = (
@ -403,12 +455,27 @@ class RealTimeStreaming:
except (AttributeError, TypeError):
pass
def _input_for_logging(
self,
message: str | dict, # mutable-ok: existing realtime input contract
) -> str | dict: # mutable-ok: logging stores concrete event dictionaries
if not self._exclude_private_content_from_logs:
return message
try:
parsed_message: Final[object] = message if isinstance(message, dict) else json.loads(message)
except (json.JSONDecodeError, TypeError):
return {} # mutable-ok: empty JSON logging payload
if not isinstance(parsed_message, dict):
return {} # mutable-ok: empty JSON logging payload
return self._message_for_logging(parsed_message)
def store_input(self, message: str | dict):
"""Store input message"""
self.input_message = message if isinstance(message, dict) else {}
logged_message: Final[str | dict] = self._input_for_logging(message) # mutable-ok: logging payload
self.input_message = logged_message if isinstance(logged_message, dict) else {}
self._collect_user_input_from_client_event(message)
if self.logging_obj:
self.logging_obj.pre_call(input=message, api_key="")
self.logging_obj.pre_call(input=logged_message, api_key="")
async def log_messages(self):
"""Log messages in list"""
@ -1009,6 +1076,8 @@ class RealTimeStreaming:
self.store_message(event_str)
self._capture_transcription_usage(event)
await self._send_event_to_client(event, event_str)
if self._is_transcription_session:
continue
blocked = await self.run_realtime_guardrails(
cast(str, transcript),
item_id=cast(str | None, event.get("item_id")),

View file

@ -0,0 +1,3 @@
from .realtime import MetaRealtime, MuseRealtimeAdapter
__all__ = ("MetaRealtime", "MuseRealtimeAdapter")

View file

@ -0,0 +1,10 @@
from .handler import MetaRealtime, MuseRealtimeAdapter
from .transformation import MuseEventTransformer, MuseProtocolError, MuseSessionConfig
__all__ = (
"MetaRealtime",
"MuseEventTransformer",
"MuseProtocolError",
"MuseRealtimeAdapter",
"MuseSessionConfig",
)

View file

@ -0,0 +1,661 @@
from __future__ import annotations
import asyncio
import base64
import binascii
import contextlib
import json
import time
import uuid
from collections.abc import Awaitable, Callable, Mapping
from typing import Final, Protocol
from urllib.parse import urlparse, urlunparse
from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
from litellm.llms.custom_httpx.http_handler import get_shared_realtime_ssl_context
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage, RealtimeQueryParams
from .transformation import (
MUSE_MODEL,
MuseEventTransformer,
MuseProtocolError,
MuseSessionConfig,
encode_event,
error_event,
parse_session_update,
session_created_event,
session_updated_event,
)
DEFAULT_MUSE_REALTIME_URL: Final = "wss://api.meta.ai/v1/asr/realtime"
_MAX_AUDIO_BACKLOG_SECONDS: Final = 4
_MAX_PENDING_PROVIDER_EVENTS: Final = 256
_JSON_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
class _ProviderWebSocket(Protocol):
async def send(self, message: str | bytes) -> None: ...
async def recv(self, decode: bool | None = None) -> str | bytes: ...
async def close(self, code: int = 1000, reason: str = "") -> None: ...
class _ClientWebSocketExceptions(Protocol):
ConnectionClosed: type[Exception]
class _ClientWebSocket(Protocol):
exceptions: _ClientWebSocketExceptions
@property
def scope(self) -> Mapping[str, object]: ...
async def send_text(self, data: str) -> None: ...
async def receive_text(self) -> str: ...
async def close(self, code: int = 1000, reason: str | None = None) -> None: ...
class WebSocketConnect(Protocol):
def __call__(
self,
url: str,
*,
open_timeout: float,
max_size: int | None,
ssl: object | None,
) -> Awaitable[_ProviderWebSocket]: ...
class MuseAdapterError(RuntimeError):
def __init__(self, message: str, *, close_code: int) -> None:
super().__init__(message)
self.close_code: Final = close_code
class MuseRealtimeAdapter:
def __init__(
self,
*,
model: str,
api_key: str,
api_base: str | None = None,
timeout: float | None = None,
websocket_connect: WebSocketConnect | None = None,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
terminate_client: Callable[[int], Awaitable[None]] | None = None,
) -> None:
if model.removeprefix("meta/") != MUSE_MODEL:
raise ValueError("unsupported Meta realtime model")
self._model: Final = model.removeprefix("meta/")
self._access_token: Final = normalize_access_token(api_key)
self._url: Final = build_muse_realtime_url(api_base)
self._timeout: Final = timeout or 10.0
self._websocket_connect = websocket_connect
self._monotonic: Final = monotonic
self._sleep: Final = sleep
self._terminate_client: Final = terminate_client
self._provider_ws: _ProviderWebSocket | None = None
self._config: MuseSessionConfig | None = None
self._session_id: str = f"sess_{uuid.uuid4().hex}"
self._events: Final[asyncio.Queue[str | BaseException]] = asyncio.Queue(maxsize=_MAX_PENDING_PROVIDER_EVENTS)
self._events.put_nowait(encode_event(session_created_event(self._model, self._session_id)))
self._transformer: Final = MuseEventTransformer()
self._audio_condition: Final = asyncio.Condition()
self._pending_audio: bytearray = bytearray()
self._audio_generation: int = 0
self._flush_requested: bool = False
self._end_requested: bool = False
self._end_stream_sent: bool = False
self._audio_consumed: bool = False
self._closed: bool = False
self._resources_closed: bool = False
self._sender_task: asyncio.Task[None] | None = None
self._receiver_task: asyncio.Task[None] | None = None
self.close_code: int = 1000
self.close_reason: str = "Session closed"
async def send(self, message: str | bytes) -> None:
if self._closed:
raise MuseAdapterError("Meta Muse realtime session is closed", close_code=self.close_code)
if isinstance(message, bytes):
await self._reject("invalid_request_error", "invalid_event", "Client events must be JSON text")
return
try:
event: Final = _parse_client_event(message)
event_type: Final = event.get("type")
if event_type in ("session.update", "transcription_session.update"):
await self._handle_session_update(message)
return
if event_type == "input_audio_buffer.append":
await self._handle_audio_append(event)
return
if event_type == "input_audio_buffer.clear":
await self._clear_audio()
return
if event_type == "input_audio_buffer.commit":
await self._commit_audio()
return
if event_type == "input_audio_buffer.end":
await self._end_audio()
return
await self._emit(
error_event(
"invalid_request_error",
"unsupported_event",
f"Event type {event_type!r} is not supported for Meta Muse transcription",
)
)
except MuseProtocolError as exc:
await self._reject("invalid_request_error", "invalid_event", str(exc))
async def recv(self, decode: bool | None = None) -> str | bytes:
event: Final = await self._events.get()
if isinstance(event, BaseException):
close_code: Final = _exception_close_code(event) if isinstance(event, Exception) else 1011
if self._terminate_client is not None:
await self._terminate_client(close_code)
raise event
return event.encode("utf-8") if decode is False else event
async def close(self, code: int = 1000, reason: str = "") -> None:
if self._resources_closed:
return
self._closed = True
self._resources_closed = True
self.close_code = sanitize_close_code(code)
self.close_reason = safe_close_reason(self.close_code)
async with self._audio_condition:
self._end_requested = True
self._audio_condition.notify_all()
tasks: Final = tuple(task for task in (self._sender_task, self._receiver_task) if task is not None)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
provider_ws: Final = self._provider_ws
if provider_ws is not None:
with contextlib.suppress(Exception):
await provider_ws.close(code=self.close_code, reason=self.close_reason)
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return self._transformer.take_unbilled_usage()
async def _handle_session_update(self, message: str) -> None:
config: Final = parse_session_update(message, self._model)
if self._config is not None:
if config != self._config:
await self._reject(
"invalid_request_error",
"session_configuration_locked",
"Meta Muse session configuration cannot change after setup",
)
return
await self._emit(session_updated_event(config, self._session_id))
return
await self._connect(config)
async def _connect(self, config: MuseSessionConfig) -> None:
connector: Final = self._websocket_connect or _default_websocket_connect
last_error: Exception | None = None # rebind-ok: records the latest bounded handshake attempt
for attempt in range(2):
provider_ws: _ProviderWebSocket | None = None
try:
provider_ws = await connector(
self._url,
open_timeout=self._timeout,
max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES,
ssl=_ssl_config(self._url),
)
await provider_ws.send(json.dumps(config.handshake(self._access_token), separators=(",", ":")))
raw_ack: str | bytes = await asyncio.wait_for( # rebind-ok: one response per handshake attempt
provider_ws.recv(), timeout=self._timeout
)
session_id: str = _parse_handshake_ack(raw_ack) # rebind-ok: one ID per handshake attempt
self._provider_ws = provider_ws
self._config = config
self._transformer.configure(config)
self._session_id = session_id
self._sender_task = asyncio.create_task(self._send_audio(), name="meta-muse-realtime-send")
self._receiver_task = asyncio.create_task(self._receive_events(), name="meta-muse-realtime-receive")
await self._emit(session_updated_event(config, session_id))
return
except asyncio.CancelledError:
if provider_ws is not None:
with contextlib.suppress(Exception):
await provider_ws.close()
raise
except Exception as exc: # noqa: BLE001 # connector implementations expose heterogeneous transport errors
last_error = exc
if provider_ws is not None:
with contextlib.suppress(Exception):
await provider_ws.close()
close_code: int = _exception_close_code(exc) # rebind-ok: classified per handshake attempt
retryable_transport_error: bool = not isinstance( # rebind-ok: classified per handshake attempt
exc, (MuseAdapterError, MuseProtocolError)
)
if attempt == 0 and retryable_transport_error and close_code in (1011, 1013):
continue
self.close_code = close_code
self.close_reason = safe_close_reason(close_code)
await self._emit(
error_event(
"server_error" if close_code != 1008 else "invalid_request_error",
"provider_connection_error",
"Meta Muse realtime handshake failed",
)
)
await self._events.put(MuseAdapterError("Meta Muse realtime handshake failed", close_code=close_code))
await self._mark_terminated(close_code)
return
assert last_error is not None
raise MuseAdapterError("Meta Muse realtime handshake failed", close_code=1011)
async def _handle_audio_append(self, event: Mapping[str, JsonValue]) -> None:
config: Final = self._require_configured()
if self._end_requested or self._end_stream_sent:
await self._reject("invalid_request_error", "input_ended", "Audio input has already ended")
return
audio_value: Final = event.get("audio")
if not isinstance(audio_value, str):
await self._reject("invalid_request_error", "invalid_audio", "Audio must be a base64 string")
return
try:
audio: Final = base64.b64decode(audio_value, validate=True)
except (binascii.Error, ValueError):
await self._reject("invalid_request_error", "invalid_audio", "Audio must be valid base64")
return
if len(audio) % 2:
await self._reject("invalid_request_error", "invalid_audio", "PCM16 audio must contain complete samples")
return
if not audio:
return
max_backlog_bytes: Final = config.bytes_per_second * _MAX_AUDIO_BACKLOG_SECONDS
if len(audio) > max_backlog_bytes:
await self._reject(
"invalid_request_error",
"audio_backlog_exceeded",
"Audio append exceeds the four-second Muse backlog limit",
)
return
async with self._audio_condition:
await self._audio_condition.wait_for(
lambda: self._closed or len(self._pending_audio) + len(audio) <= max_backlog_bytes
)
if self._closed:
raise MuseAdapterError("Meta Muse realtime session is closed", close_code=self.close_code)
self._pending_audio.extend(audio)
self._audio_condition.notify_all()
async def _clear_audio(self) -> None:
self._require_configured()
async with self._audio_condition:
self._pending_audio.clear()
self._audio_generation += 1
self._flush_requested = False
self._audio_condition.notify_all()
await self._emit(
{ # mutable-ok: OpenAI-compatible JSON event
"type": "input_audio_buffer.cleared",
"event_id": f"event_{uuid.uuid4().hex}",
}
)
async def _commit_audio(self) -> None:
config: Final = self._require_configured()
previous_item_id, item_id = self._transformer.commit_item()
async with self._audio_condition:
self._flush_requested = True
if config.mode == "PUSH_TO_TALK":
self._end_requested = True
self._audio_condition.notify_all()
await self._emit(
{ # mutable-ok: OpenAI-compatible JSON event
"type": "input_audio_buffer.committed",
"event_id": f"event_{uuid.uuid4().hex}",
"previous_item_id": previous_item_id,
"item_id": item_id,
}
)
async def _end_audio(self) -> None:
self._require_configured()
async with self._audio_condition:
self._flush_requested = True
self._end_requested = True
self._audio_condition.notify_all()
async def _send_audio(self) -> None:
config: Final = self._require_configured()
provider_ws: Final = self._require_provider_ws()
pacing_origin: float | None = None # rebind-ok: initialized when the first packet is ready
sent_duration: float = 0.0 # rebind-ok: absolute pacing clock advances after each packet
try:
while True:
packet, pacing_origin, ended = await self._next_audio_packet(
config,
pacing_origin,
sent_duration,
)
if ended:
break
if packet is None:
continue
await provider_ws.send(packet)
self._audio_consumed = True
sent_duration += len(packet) / config.bytes_per_second
await self._send_end_stream()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 # WebSocket implementations expose heterogeneous transport errors
await self._fail_provider(exc, phase="audio send")
async def _next_audio_packet(
self,
config: MuseSessionConfig,
pacing_origin: float | None,
sent_duration: float,
) -> tuple[bytes | None, float | None, bool]:
async with self._audio_condition:
await self._audio_condition.wait_for(
lambda: (
self._closed
or len(self._pending_audio) >= config.packet_bytes
or (self._flush_requested and bool(self._pending_audio))
or (self._end_requested and not self._pending_audio)
)
)
if self._closed or (self._end_requested and not self._pending_audio):
return None, pacing_origin, True
packet_size: Final = min(config.packet_bytes, len(self._pending_audio))
if packet_size < config.packet_bytes and not self._flush_requested:
return None, pacing_origin, False
generation: Final = self._audio_generation
current_time: Final = self._monotonic()
effective_origin: Final = (
current_time - sent_duration
if pacing_origin is None or current_time > pacing_origin + sent_duration
else pacing_origin
)
deadline: Final = effective_origin + sent_duration
delay: Final = deadline - self._monotonic()
if delay > 0:
await self._sleep(delay)
async with self._audio_condition:
if generation != self._audio_generation:
return None, effective_origin, False
actual_size: Final = min(packet_size, len(self._pending_audio))
packet: Final = bytes(self._pending_audio[:actual_size])
del self._pending_audio[:actual_size]
if not self._pending_audio:
self._flush_requested = False
self._audio_condition.notify_all()
return packet or None, effective_origin, False
async def _send_end_stream(self) -> None:
if self._end_stream_sent:
return
provider_ws: Final = self._require_provider_ws()
await provider_ws.send('{"type":"endStream"}')
self._end_stream_sent = True
async def _receive_events(self) -> None:
provider_ws: Final = self._require_provider_ws()
try:
while True:
raw: str | bytes = await provider_ws.recv() # rebind-ok: one provider frame per iteration
if not isinstance(raw, str):
raise MuseProtocolError("provider returned a non-text event")
for event in self._transformer.transform(raw):
await self._emit(event)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 # provider close exceptions vary by WebSocket implementation
close_code: Final = _exception_close_code(exc)
if close_code == 1000 and self._end_stream_sent:
await self._mark_terminated(1000)
await self._events.put(MuseAdapterError("Meta Muse realtime session completed", close_code=1000))
return
failure: Final = MuseAdapterError(
"Meta Muse realtime closed before input ended",
close_code=1011 if close_code == 1000 else close_code,
)
await self._fail_provider(failure, phase="receive")
async def _fail_provider(self, exc: Exception, *, phase: str) -> None:
close_code: Final = _exception_close_code(exc)
self.close_code = close_code
self.close_reason = safe_close_reason(close_code)
await self._emit(
error_event(
"server_error",
"provider_connection_error",
f"Meta Muse realtime {phase} failed",
)
)
await self._events.put(MuseAdapterError(f"Meta Muse realtime {phase} failed", close_code=close_code))
await self._mark_terminated(close_code)
async def _mark_terminated(self, close_code: int) -> None:
self._closed = True
self.close_code = sanitize_close_code(close_code)
self.close_reason = safe_close_reason(self.close_code)
async with self._audio_condition:
self._audio_condition.notify_all()
async def _terminate(self, close_code: int) -> None:
await self._mark_terminated(close_code)
if self._terminate_client is not None:
await self._terminate_client(self.close_code)
async def _reject(self, error_type: str, code: str, message: str) -> None:
self.close_code = 1008
self.close_reason = safe_close_reason(1008)
await self._emit(error_event(error_type, code, message))
await self._events.put(MuseAdapterError(message, close_code=1008))
await self._mark_terminated(1008)
async def _emit(self, event: Mapping[str, object]) -> None:
await self._events.put(encode_event(event))
def _require_configured(self) -> MuseSessionConfig:
if self._config is None:
raise MuseProtocolError("send session.update before audio events")
return self._config
def _require_provider_ws(self) -> _ProviderWebSocket:
if self._provider_ws is None:
raise MuseProtocolError("Meta Muse provider connection is not ready")
return self._provider_ws
class MetaRealtime:
async def async_realtime(
self,
model: str,
websocket: _ClientWebSocket,
logging_obj: LiteLLMLogging,
api_base: str | None = None,
api_key: str | None = None,
client: object | None = None,
timeout: float | None = None,
query_params: RealtimeQueryParams | None = None,
user_api_key_dict: object | None = None,
litellm_metadata: Mapping[str, object] | None = None,
websocket_connect: WebSocketConnect | None = None,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
**kwargs: object, # kwargs-ok: realtime dispatcher forwards provider-neutral options
) -> None:
if api_key is None or not api_key.strip():
await _send_client_error_and_close(websocket, "Meta Model API key is required")
return
try:
adapter: Final = MuseRealtimeAdapter(
model=model,
api_key=api_key,
api_base=api_base,
timeout=timeout,
websocket_connect=websocket_connect,
monotonic=monotonic,
sleep=sleep,
terminate_client=lambda code: _close_client(websocket, code),
)
except ValueError:
await _send_client_error_and_close(websocket, "Invalid Meta Muse realtime configuration")
return
realtime_streaming: Final = RealTimeStreaming(
websocket,
adapter, # pyright: ignore[reportArgumentType] # raw adapter intentionally matches the websocket surface
logging_obj,
model=model,
user_api_key_dict=user_api_key_dict,
request_data={ # mutable-ok: relay request metadata payload
"litellm_metadata": dict(litellm_metadata or {}) # mutable-ok: relay owns its metadata copy
},
force_transcription_model=model,
usage_provider=adapter,
exclude_private_content_from_logs=True,
)
try:
await realtime_streaming.bidirectional_forward()
except MuseAdapterError as exc:
adapter.close_code = exc.close_code
adapter.close_reason = safe_close_reason(exc.close_code)
except Exception: # noqa: BLE001 # relay errors are normalized before closing the accepted client socket
adapter.close_code = 1011
adapter.close_reason = safe_close_reason(1011)
verbose_proxy_logger.exception("Meta Muse realtime session failed")
finally:
await adapter.close(code=adapter.close_code)
await _close_client(websocket, adapter.close_code)
def normalize_access_token(api_key: str) -> str:
stripped: Final = api_key.strip()
if not stripped:
raise ValueError("Meta Model API key is required")
parts: Final = stripped.split(None, 1)
if parts[0].casefold() == "bearer":
if len(parts) != 2 or not parts[1].strip():
raise ValueError("Meta Model API key must include a token after Bearer")
return f"Bearer {parts[1].strip()}"
return f"Bearer {stripped}"
def build_muse_realtime_url(api_base: str | None) -> str:
if api_base is None:
return DEFAULT_MUSE_REALTIME_URL
parsed: Final = urlparse(api_base.strip())
scheme: Final = "wss" if parsed.scheme == "https" else parsed.scheme
if (
scheme != "wss"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
):
raise ValueError("Meta api_base must be an absolute wss:// or https:// URL without credentials or a fragment")
netloc: Final = f"{parsed.hostname}:{parsed.port}" if parsed.port is not None else parsed.hostname
return urlunparse((scheme, netloc, "/v1/asr/realtime", "", "", ""))
def sanitize_close_code(code: int | None) -> int:
if code is not None and code in (1000, 1008, 1011, 1013):
return code
return 1011
def safe_close_reason(code: int) -> str:
return { # mutable-ok: immutable-by-convention close-reason lookup
1000: "Session closed",
1008: "Invalid realtime transcription request",
1011: "Realtime transcription service error",
1013: "Realtime transcription service unavailable",
}.get(code, "Realtime transcription service error")
def _parse_client_event(payload: str) -> Mapping[str, JsonValue]:
try:
value: Final = _JSON_ADAPTER.validate_json(payload)
except ValidationError:
raise MuseProtocolError("invalid JSON object") from None
if not isinstance(value, dict):
raise MuseProtocolError("message must be a JSON object")
event_type: Final = value.get("type")
if not isinstance(event_type, str) or not event_type:
raise MuseProtocolError("message type must be a non-empty string")
return value
def _parse_handshake_ack(raw: str | bytes) -> str:
if not isinstance(raw, str):
raise MuseProtocolError("provider returned a non-text handshake response")
message: Final = _parse_json_object(raw)
if message.get("type") == "error":
raise MuseAdapterError("Meta Muse realtime handshake was rejected", close_code=1008)
session_id: Final = message.get("sessionId")
if not isinstance(session_id, str) or not session_id.strip():
raise MuseProtocolError("provider returned an invalid handshake response")
return session_id.strip()
def _parse_json_object(payload: str) -> Mapping[str, JsonValue]:
try:
value: Final = _JSON_ADAPTER.validate_json(payload)
except ValidationError:
raise MuseProtocolError("invalid provider JSON object") from None
if not isinstance(value, dict):
raise MuseProtocolError("provider message must be a JSON object")
return value
def _exception_close_code(exc: Exception) -> int:
code: Final = getattr(exc, "code", None)
if isinstance(exc, MuseAdapterError):
return sanitize_close_code(exc.close_code)
return sanitize_close_code(code if isinstance(code, int) else None)
def _ssl_config(url: str) -> object | None:
if not url.startswith("wss://"):
return None
config: Final = get_shared_realtime_ssl_context()
return True if config is False else config
async def _default_websocket_connect(
url: str,
*,
open_timeout: float,
max_size: int | None,
ssl: object | None,
) -> _ProviderWebSocket:
import websockets
connection: Final = await websockets.connect(
url,
open_timeout=open_timeout,
max_size=max_size,
ssl=ssl, # pyright: ignore[reportArgumentType] # shared SSL helper returns the library-supported union
)
return connection
async def _send_client_error_and_close(websocket: _ClientWebSocket, message: str) -> None:
with contextlib.suppress(Exception):
await websocket.send_text(encode_event(error_event("invalid_request_error", "invalid_configuration", message)))
await _close_client(websocket, 1008)
async def _close_client(websocket: _ClientWebSocket, code: int) -> None:
with contextlib.suppress(Exception):
await websocket.close(code=sanitize_close_code(code), reason=safe_close_reason(sanitize_close_code(code)))

View file

@ -0,0 +1,619 @@
from __future__ import annotations
import json
import math
import uuid
from collections import OrderedDict, deque
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final, Literal, TypeAlias
from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage
MUSE_MODEL: Final = "muse-voice-transcribe-1.0"
SUPPORTED_SAMPLE_RATES: Final = frozenset((16_000, 24_000))
SUPPORTED_MODES: Final = frozenset(("PUSH_TO_TALK", "ENDPOINTING", "DIARIZATION"))
SUPPORTED_LANGUAGES: Final = (
"Arabic",
"Bengali",
"Dutch",
"English",
"French",
"German",
"Hebrew",
"Hindi",
"Indonesian",
"Italian",
"Japanese",
"Kannada",
"Korean",
"Malay",
"Mandarin Chinese",
"Marathi",
"Polish",
"Portuguese",
"Spanish",
"Tagalog",
"Tamil",
"Telugu",
"Thai",
"Turkish",
"Vietnamese",
)
_LANGUAGE_NAMES: Final = { # mutable-ok: immutable-by-convention language lookup table
language.casefold(): language for language in SUPPORTED_LANGUAGES
}
_LANGUAGE_CODES: Final = { # mutable-ok: immutable-by-convention language lookup table
"ar": "Arabic",
"bn": "Bengali",
"de": "German",
"en": "English",
"es": "Spanish",
"fil": "Tagalog",
"fr": "French",
"he": "Hebrew",
"hi": "Hindi",
"id": "Indonesian",
"it": "Italian",
"iw": "Hebrew",
"ja": "Japanese",
"kn": "Kannada",
"ko": "Korean",
"ms": "Malay",
"mr": "Marathi",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"ta": "Tamil",
"te": "Telugu",
"th": "Thai",
"tl": "Tagalog",
"tr": "Turkish",
"vi": "Vietnamese",
"zh": "Mandarin Chinese",
}
_JSON_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
OpenAIEvent: TypeAlias = Mapping[str, object]
class MuseProtocolError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class MuseSessionConfig:
model: str
mode: Literal["PUSH_TO_TALK", "ENDPOINTING", "DIARIZATION"]
sample_rate: Literal[16000, 24000]
keywords: tuple[str, ...]
language_bias: tuple[str, ...]
@property
def audio_encoding(self) -> Literal["PCM_16KHZ", "PCM_24KHZ"]:
return "PCM_16KHZ" if self.sample_rate == 16_000 else "PCM_24KHZ"
@property
def bytes_per_second(self) -> int:
return self.sample_rate * 2
@property
def packet_bytes(self) -> int:
return self.bytes_per_second * 80 // 1000
def handshake(self, access_token: str) -> Mapping[str, object]:
base: Final[Mapping[str, object]] = { # mutable-ok: JSON wire payload
"mode": self.mode,
"authorization": {"accessToken": access_token}, # mutable-ok: JSON wire payload
"audioEncoding": self.audio_encoding,
"model": self.model,
"partialMode": "CUMULATIVE",
"emitAudioProgress": True,
}
payload: dict[str, object] = dict(base) # mutable-ok: incrementally builds JSON wire payload
if self.keywords:
payload["keywords"] = list(self.keywords) # mutable-ok: JSON arrays require concrete lists
if self.language_bias:
payload["languageBias"] = list(self.language_bias) # mutable-ok: JSON arrays require concrete lists
return payload
def openai_session(self, session_id: str) -> Mapping[str, object]:
turn_detection: Final[Mapping[str, object] | None] = (
None if self.mode == "PUSH_TO_TALK" else {"type": "server_vad"} # mutable-ok: JSON wire payload
)
transcription: dict[str, object] = { # mutable-ok: incrementally builds JSON wire payload
"model": self.model,
}
if self.language_bias:
transcription["language"] = self.language_bias[0]
transcription["language_bias"] = list( # mutable-ok: JSON arrays require concrete lists
self.language_bias
)
if self.keywords:
transcription["keywords"] = list(self.keywords) # mutable-ok: JSON arrays require concrete lists
return { # mutable-ok: JSON wire payload
"id": session_id,
"object": "realtime.transcription_session",
"type": "transcription",
"model": self.model,
"audio": { # mutable-ok: JSON wire payload
"input": { # mutable-ok: JSON wire payload
"format": {"type": "audio/pcm", "rate": self.sample_rate}, # mutable-ok: JSON wire payload
"transcription": transcription,
"turn_detection": turn_detection,
}
},
}
@dataclass(slots=True)
class _TurnState:
item_id: str | None = None
started: bool = False
start_emitted: bool = False
latest_partial: str | None = None
emitted_partial: str = ""
final_text: str | None = None
completed_signal: bool = False
completed_emitted: bool = False
stopped: bool = False
stopped_emitted: bool = False
speaker: str | None = None
def _json_object(payload: str) -> Mapping[str, JsonValue]:
try:
value: Final = _JSON_ADAPTER.validate_json(payload)
except ValidationError:
raise MuseProtocolError("invalid JSON object") from None
if not isinstance(value, dict):
raise MuseProtocolError("message must be a JSON object")
return value
def _mapping(value: JsonValue | None, name: str) -> Mapping[str, JsonValue]:
if value is None:
return {} # mutable-ok: empty JSON object
if not isinstance(value, dict):
raise MuseProtocolError(f"{name} must be an object")
return value
def _string(value: JsonValue | None, name: str) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise MuseProtocolError(f"{name} must be a string")
return value
def _normalize_model(model: str) -> str:
return model.removeprefix("meta/").strip()
def normalize_language(language: str) -> str:
value: Final = language.strip()
if not value:
raise MuseProtocolError("language must be non-empty")
documented_name: Final = _LANGUAGE_NAMES.get(value.casefold())
if documented_name is not None:
return documented_name
primary: Final = value.replace("_", "-").split("-", 1)[0].casefold()
mapped_name: Final = _LANGUAGE_CODES.get(primary)
if mapped_name is None:
raise MuseProtocolError("unsupported Muse Voice language")
return mapped_name
def _normalize_string_sequence(value: JsonValue | None, name: str) -> tuple[str, ...]:
if value is None:
return ()
if not isinstance(value, list):
raise MuseProtocolError(f"{name} must be an array of strings")
normalized: list[str] = [] # mutable-ok: deduplicates validated language hints before freezing
for entry in value:
if not isinstance(entry, str) or not entry.strip():
raise MuseProtocolError(f"{name} entries must be non-empty strings")
item: str = entry.strip() # rebind-ok: normalized once for each hint
if item not in normalized:
normalized.append(item)
return tuple(normalized)
def _normalize_language_sequence(value: JsonValue | None) -> tuple[str, ...]:
return tuple(dict.fromkeys(normalize_language(item) for item in _normalize_string_sequence(value, "language_bias")))
def _parse_sample_rate(session: Mapping[str, JsonValue]) -> Literal[16000, 24000]:
beta_format: Final = session.get("input_audio_format")
audio: Final = _mapping(session.get("audio"), "session.audio")
audio_input: Final = _mapping(audio.get("input"), "session.audio.input")
ga_format: Final = audio_input.get("format")
if beta_format is not None and ga_format is not None:
raise MuseProtocolError("input audio format must use either beta or GA layout")
if beta_format is not None:
if beta_format != "pcm16":
raise MuseProtocolError("Muse Voice requires pcm16 input audio")
return 24_000
if ga_format is None:
return 24_000
if isinstance(ga_format, str):
if ga_format != "pcm16":
raise MuseProtocolError("Muse Voice requires audio/pcm input audio")
return 24_000
format_mapping: Final = _mapping(ga_format, "session.audio.input.format")
if format_mapping.get("type") != "audio/pcm":
raise MuseProtocolError("Muse Voice requires audio/pcm input audio")
channels: Final = format_mapping.get("channels", 1)
if isinstance(channels, bool) or channels != 1:
raise MuseProtocolError("Muse Voice requires mono input audio")
rate: Final = format_mapping.get("rate", 24_000)
if isinstance(rate, bool) or not isinstance(rate, int) or rate not in SUPPORTED_SAMPLE_RATES:
raise MuseProtocolError("Muse Voice supports PCM16 at 16000 Hz or 24000 Hz")
return rate
def _parse_mode(
session: Mapping[str, JsonValue], audio_input: Mapping[str, JsonValue]
) -> Literal["PUSH_TO_TALK", "ENDPOINTING", "DIARIZATION"]:
explicit: Final = session.get("mode")
if explicit is not None:
if not isinstance(explicit, str) or explicit.upper() not in SUPPORTED_MODES:
raise MuseProtocolError("unsupported Muse Voice mode")
normalized_mode: Final = explicit.upper()
if normalized_mode == "PUSH_TO_TALK":
return "PUSH_TO_TALK"
if normalized_mode == "DIARIZATION":
return "DIARIZATION"
return "ENDPOINTING"
turn_detection_present: Final = "turn_detection" in session or "turn_detection" in audio_input
turn_detection: Final = session.get("turn_detection", audio_input.get("turn_detection"))
if turn_detection_present and turn_detection is None:
return "PUSH_TO_TALK"
if turn_detection is None:
return "ENDPOINTING"
turn_detection_mapping: Final = _mapping(turn_detection, "turn_detection")
if turn_detection_mapping.get("type") not in (None, "server_vad"):
raise MuseProtocolError("Muse Voice supports server_vad turn detection or null")
return "ENDPOINTING"
def parse_session_update(payload: str, expected_model: str) -> MuseSessionConfig:
message: Final = _json_object(payload)
if message.get("type") not in ("session.update", "transcription_session.update"):
raise MuseProtocolError("expected session.update")
session: Final = _mapping(message.get("session"), "session")
if not session:
raise MuseProtocolError("session.update requires a session object")
session_type: Final = session.get("type")
if session_type not in (None, "transcription", "realtime"):
raise MuseProtocolError("Muse Voice supports transcription sessions only")
audio: Final = _mapping(session.get("audio"), "session.audio")
audio_input: Final = _mapping(audio.get("input"), "session.audio.input")
beta_transcription: Final = session.get("input_audio_transcription")
ga_transcription: Final = audio_input.get("transcription")
if beta_transcription is not None and ga_transcription is not None:
raise MuseProtocolError("input transcription must use either beta or GA layout")
transcription: Final = _mapping(
beta_transcription if beta_transcription is not None else ga_transcription,
"input audio transcription",
)
requested_model: Final = _string(transcription.get("model"), "transcription model")
normalized_model: Final = _normalize_model(expected_model)
if normalized_model != MUSE_MODEL:
raise MuseProtocolError("unsupported Meta realtime model")
if requested_model is not None and _normalize_model(requested_model) != normalized_model:
raise MuseProtocolError("realtime session model cannot be changed")
language_value: Final = _string(transcription.get("language"), "language")
explicit_bias: Final = _normalize_language_sequence(transcription.get("language_bias"))
language_bias: Final = tuple(
dict.fromkeys((normalize_language(language_value), *explicit_bias))
if language_value is not None
else explicit_bias
)
keywords: Final = _normalize_string_sequence(transcription.get("keywords"), "keywords")
return MuseSessionConfig(
model=normalized_model,
mode=_parse_mode(session, audio_input),
sample_rate=_parse_sample_rate(session),
keywords=keywords,
language_bias=language_bias,
)
def session_created_event(model: str, session_id: str) -> OpenAIEvent:
normalized_model: Final = _normalize_model(model)
default_config: Final = MuseSessionConfig(
model=normalized_model,
mode="ENDPOINTING",
sample_rate=24_000,
keywords=(),
language_bias=(),
)
return { # mutable-ok: OpenAI-compatible JSON event
"type": "session.created",
"event_id": f"event_{uuid.uuid4().hex}",
"session": default_config.openai_session(session_id),
}
def session_updated_event(config: MuseSessionConfig, session_id: str) -> OpenAIEvent:
return { # mutable-ok: OpenAI-compatible JSON event
"type": "session.updated",
"event_id": f"event_{uuid.uuid4().hex}",
"session": config.openai_session(session_id),
}
def error_event(error_type: str, code: str, message: str) -> OpenAIEvent:
return { # mutable-ok: OpenAI-compatible JSON event
"type": "error",
"event_id": f"event_{uuid.uuid4().hex}",
"error": { # mutable-ok: nested OpenAI-compatible error object
"type": error_type,
"code": code,
"message": message,
},
}
class MuseEventTransformer:
def __init__(self, *, completed_turn_limit: int = 128) -> None:
self._turns: OrderedDict[str, _TurnState] = OrderedDict() # mutable-ok: ordered active-turn state
self._active_turn_id: str | None = None
self._mode: Literal["PUSH_TO_TALK", "ENDPOINTING", "DIARIZATION"] = "ENDPOINTING"
self._completed_turn_ids: set[str] = set() # mutable-ok: bounded completed-turn membership
self._completed_turn_order: deque[str] = deque( # mutable-ok: bounded completion eviction order
maxlen=completed_turn_limit
)
self._completed_turn_limit: Final = completed_turn_limit
self._pending_item_ids: deque[str] = deque() # mutable-ok: FIFO commit correlation state
self._last_committed_item_id: str | None = None
self._last_audio_processed_ms: float = 0.0
self._unassigned_usage_seconds: float = 0.0
def configure(self, config: MuseSessionConfig) -> None:
self._mode = config.mode
def transform(self, payload: str) -> tuple[OpenAIEvent, ...]:
message: Final = _json_object(payload)
event_type: Final = message.get("type")
if event_type == "error":
return (error_event("server_error", "provider_error", "Meta Muse realtime transcription failed"),)
if event_type == "audioProgress":
self._update_audio_progress(message)
return ()
if event_type == "speechStart":
self._speech_start(message)
elif event_type == "transcript":
self._transcript(message)
elif event_type == "speaker":
self._speaker(message)
elif event_type == "speechEnd":
self._speech_end(message)
elif event_type == "speechComplete":
self._speech_complete(message)
else:
return ()
return self._drain()
def commit_item(self) -> tuple[str | None, str]:
previous_item_id: Final = self._last_committed_item_id
provider_turn_id: Final = self._active_turn_id
active_turn: Final = self._turns.get(provider_turn_id) if provider_turn_id is not None else None
item_id: Final = (
active_turn.item_id or provider_turn_id
if active_turn is not None and provider_turn_id is not None
else f"item_{uuid.uuid4().hex}"
)
if active_turn is not None:
active_turn.item_id = item_id
else:
self._pending_item_ids.append(item_id)
self._last_committed_item_id = item_id
return previous_item_id, item_id
def take_unbilled_usage(self) -> RealtimeInputAudioTranscriptionUsage | None:
seconds: Final = self._unassigned_usage_seconds
if seconds <= 0:
return None
self._unassigned_usage_seconds = 0.0
return {"type": "duration", "seconds": seconds} # mutable-ok: typed usage wire payload
def _turn(self, turn_id: str) -> _TurnState:
if turn_id in self._completed_turn_ids:
raise _CompletedTurn
turn: Final = self._turns.get(turn_id)
if turn is not None:
return turn
created: Final = _TurnState(item_id=self._pending_item_ids.popleft() if self._pending_item_ids else turn_id)
self._turns[turn_id] = created
return created
def _speech_start(self, message: Mapping[str, JsonValue]) -> None:
turn_id: Final = self._required_turn_id(message, "speechStart")
try:
turn: Final = self._turn(turn_id)
except _CompletedTurn:
return
turn.started = True
self._active_turn_id = turn_id
def _transcript(self, message: Mapping[str, JsonValue]) -> None:
transcript: Final = message.get("transcript")
if not isinstance(transcript, str):
raise MuseProtocolError("transcript event has invalid transcript")
if not transcript and message.get("turnId") is None and self._active_turn_id is None:
return
turn_id: Final = self._transcript_turn_id(message)
try:
turn: Final = self._turn(turn_id)
except _CompletedTurn:
return
final: Final = message.get("final") is True
if final:
turn.final_text = transcript
turn.completed_signal = True
if self._mode == "PUSH_TO_TALK":
turn.stopped = True
if self._active_turn_id == turn_id:
self._active_turn_id = None
return
if turn.final_text is None:
turn.latest_partial = transcript
def _speaker(self, message: Mapping[str, JsonValue]) -> None:
turn_id: Final = (
self._required_turn_id(message, "speaker") if message.get("turnId") is not None else self._active_turn_id
)
if turn_id is None:
raise MuseProtocolError("speaker event arrived outside an active turn")
label: Final = message.get("label")
if not isinstance(label, str) or not label.strip():
raise MuseProtocolError("speaker event has invalid label")
try:
turn: Final = self._turn(turn_id)
except _CompletedTurn:
return
turn.speaker = label.strip()
def _speech_end(self, message: Mapping[str, JsonValue]) -> None:
turn_id: Final = self._required_turn_id(message, "speechEnd")
try:
turn: Final = self._turn(turn_id)
except _CompletedTurn:
return
turn.stopped = True
if self._active_turn_id == turn_id:
self._active_turn_id = None
def _speech_complete(self, message: Mapping[str, JsonValue]) -> None:
turn_id: Final = self._required_turn_id(message, "speechComplete")
transcript: Final = message.get("transcript")
if not isinstance(transcript, str):
raise MuseProtocolError("speechComplete event has invalid transcript")
try:
turn: Final = self._turn(turn_id)
except _CompletedTurn:
return
turn.final_text = transcript
turn.completed_signal = True
def _update_audio_progress(self, message: Mapping[str, JsonValue]) -> None:
processed_ms: Final = message.get("audioProcessedMs")
if (
isinstance(processed_ms, bool)
or not isinstance(processed_ms, (int, float))
or not math.isfinite(processed_ms)
or processed_ms < 0
):
raise MuseProtocolError("audioProgress event has invalid audioProcessedMs")
if processed_ms <= self._last_audio_processed_ms:
return
self._unassigned_usage_seconds += (float(processed_ms) - self._last_audio_processed_ms) / 1000
self._last_audio_processed_ms = float(processed_ms)
def _drain(self) -> tuple[OpenAIEvent, ...]:
events: list[OpenAIEvent] = [] # mutable-ok: ordered events are frozen to a tuple before return
while self._turns:
turn_id: str = next(iter(self._turns)) # rebind-ok: selects the next ordered turn
turn: _TurnState = self._turns[turn_id] # rebind-ok: state for the selected turn
has_content: bool = ( # rebind-ok: evaluated for the selected turn
turn.latest_partial is not None or turn.final_text is not None
)
item_id: str = turn.item_id or turn_id # rebind-ok: selected for each ordered turn
if (turn.started or has_content) and not turn.start_emitted:
turn.start_emitted = True
events.append(self._speech_event("input_audio_buffer.speech_started", item_id))
if turn.latest_partial is not None and turn.final_text is None:
delta: str = self._new_suffix( # rebind-ok: computed for the selected turn
turn.emitted_partial, turn.latest_partial
)
if delta:
turn.emitted_partial = turn.latest_partial
events.append(
{ # mutable-ok: OpenAI-compatible JSON event
"type": "conversation.item.input_audio_transcription.delta",
"event_id": f"event_{uuid.uuid4().hex}",
"item_id": item_id,
"content_index": 0,
"delta": delta,
}
)
if turn.stopped and not turn.stopped_emitted:
turn.stopped_emitted = True
events.append(self._speech_event("input_audio_buffer.speech_stopped", item_id))
if turn.final_text is not None and turn.stopped_emitted and not turn.completed_emitted:
turn.completed_emitted = True
usage: RealtimeInputAudioTranscriptionUsage | None = ( # rebind-ok: usage assigned per turn
self.take_unbilled_usage()
)
completed_event: dict[str, object] = { # mutable-ok: incrementally builds OpenAI JSON event
"type": "conversation.item.input_audio_transcription.completed",
"event_id": f"event_{uuid.uuid4().hex}",
"item_id": item_id,
"content_index": 0,
"transcript": turn.final_text,
}
if turn.speaker is not None:
completed_event["speaker"] = turn.speaker
if usage is not None:
completed_event["usage"] = usage
events.append(completed_event)
if not (turn.completed_emitted and (turn.stopped or turn.completed_signal)):
break
del self._turns[turn_id]
self._remember_completed(turn_id)
return tuple(events)
def _remember_completed(self, turn_id: str) -> None:
if turn_id in self._completed_turn_ids:
return
if len(self._completed_turn_order) >= self._completed_turn_limit:
self._completed_turn_ids.discard(self._completed_turn_order.popleft())
self._completed_turn_order.append(turn_id)
self._completed_turn_ids.add(turn_id)
def _transcript_turn_id(self, message: Mapping[str, JsonValue]) -> str:
if message.get("turnId") is not None:
return self._required_turn_id(message, "transcript")
if self._active_turn_id is not None:
return self._active_turn_id
if self._mode != "PUSH_TO_TALK":
raise MuseProtocolError("transcript event is missing turnId outside an active turn")
turn_id: Final = f"item_{uuid.uuid4().hex}"
self._active_turn_id = turn_id
return turn_id
@staticmethod
def _required_turn_id(message: Mapping[str, JsonValue], event: str) -> str:
value: Final = message.get("turnId")
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise MuseProtocolError(f"{event} event has invalid turnId")
turn_id: Final = str(value).strip()
if not turn_id:
raise MuseProtocolError(f"{event} event has invalid turnId")
return turn_id
@staticmethod
def _speech_event(event_type: str, turn_id: str) -> OpenAIEvent:
return { # mutable-ok: OpenAI-compatible JSON event
"type": event_type,
"event_id": f"event_{uuid.uuid4().hex}",
"item_id": turn_id,
}
@staticmethod
def _new_suffix(previous: str, current: str) -> str:
if current.startswith(previous):
return current[len(previous) :]
return ""
class _CompletedTurn(Exception):
pass
def encode_event(event: Mapping[str, object]) -> str:
return json.dumps(event, separators=(",", ":"))

View file

@ -173,7 +173,7 @@
"api_key_env": "META_API_KEY",
"api_base_env": "META_API_BASE",
"base_class": "openai_gpt",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/realtime"]
},
"cognition": {
"base_url": "https://api.cognition.ai/v1",

View file

@ -34717,6 +34717,21 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta/muse-voice-transcribe-1.0": {
"litellm_provider": "meta",
"mode": "audio_transcription",
"source": "https://dev.meta.ai/docs/speech-to-text",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,
@ -59098,9 +59113,9 @@
"litellm_provider": "wandb",
"mode": "chat",
"supports_reasoning": true,
"input_cost_per_token": 0.00000131,
"output_cost_per_token": 0.00000396,
"cache_read_input_token_cost": 0.000000044,
"input_cost_per_token": 1.31e-06,
"output_cost_per_token": 3.96e-06,
"cache_read_input_token_cost": 4.4e-08,
"supports_prompt_caching": true,
"source": "https://wandb.ai/site/pricing/tokens/"
},
@ -59108,9 +59123,9 @@
"litellm_provider": "wandb",
"mode": "chat",
"supports_reasoning": true,
"input_cost_per_token": 0.0000001,
"output_cost_per_token": 0.00000015,
"cache_read_input_token_cost": 0.00000005,
"input_cost_per_token": 1e-07,
"output_cost_per_token": 1.5e-07,
"cache_read_input_token_cost": 5e-08,
"supports_prompt_caching": true,
"source": "https://wandb.ai/site/pricing/tokens/"
},

View file

@ -333,7 +333,7 @@ async def _resolve_vertex_access_token_bounded(
@wrapper_client
async def _arealtime(
async def _arealtime( # noqa: C901 # central dispatcher branches once per supported realtime provider
model: str,
websocket: "WebSocket", # fastapi websocket
api_base: str | None = None,
@ -391,7 +391,37 @@ async def _arealtime(
model=model,
provider=LlmProviders(_custom_llm_provider),
)
if provider_config is not None:
if _custom_llm_provider == LlmProviders.META.value:
if model != "muse-voice-transcribe-1.0":
raise ValueError(f"Unsupported Meta realtime model: {model}")
if query_params is None or query_params.get("intent") != "transcription":
raise ValueError("Meta Muse Voice realtime requires intent=transcription")
from litellm.llms.meta.realtime.handler import MetaRealtime
meta_api_key: Final = get_secret_str("META_API_KEY")
dynamic_key_override: Final = dynamic_api_key if dynamic_api_key != meta_api_key else None
resolved_meta_api_key: Final = (
api_key
or litellm_params.api_key
or dynamic_key_override
or get_secret_str("MODEL_API_KEY")
or dynamic_api_key
or meta_api_key
)
await MetaRealtime().async_realtime(
model=model,
websocket=websocket,
logging_obj=litellm_logging_obj,
api_base=dynamic_api_base or litellm_params.api_base or api_base,
api_key=resolved_meta_api_key,
client=client,
timeout=timeout,
query_params=query_params,
user_api_key_dict=kwargs.get("user_api_key_dict"),
litellm_metadata=_build_litellm_metadata(kwargs),
)
elif provider_config is not None:
await base_llm_http_handler.async_realtime(
model=model,
websocket=websocket,

View file

@ -2202,6 +2202,8 @@ class OpenAIRealtimeInputAudioTranscriptionCompleted(TypedDict):
item_id: ReadOnly[str]
content_index: ReadOnly[int]
transcript: ReadOnly[str]
usage: NotRequired[ReadOnly[Mapping[str, object]]]
speaker: NotRequired[ReadOnly[str]]
class OpenAIRealtimeUsageTokenDetails(TypedDict):

View file

@ -169,9 +169,19 @@ class RealtimeInputAudioTranscriptionUsageInputTokenDetails(TypedDict):
audio_tokens: ReadOnly[int]
class RealtimeInputAudioTranscriptionUsage(TypedDict):
class RealtimeInputAudioTranscriptionTokenUsage(TypedDict):
type: ReadOnly[Literal["tokens"]]
input_tokens: ReadOnly[int]
output_tokens: ReadOnly[int]
total_tokens: ReadOnly[int]
input_token_details: ReadOnly[RealtimeInputAudioTranscriptionUsageInputTokenDetails]
class RealtimeInputAudioTranscriptionDurationUsage(TypedDict):
type: ReadOnly[Literal["duration"]]
seconds: ReadOnly[float]
RealtimeInputAudioTranscriptionUsage = (
RealtimeInputAudioTranscriptionTokenUsage | RealtimeInputAudioTranscriptionDurationUsage
)

View file

@ -34717,6 +34717,21 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta/muse-voice-transcribe-1.0": {
"litellm_provider": "meta",
"mode": "audio_transcription",
"source": "https://dev.meta.ai/docs/speech-to-text",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,
@ -59098,9 +59113,9 @@
"litellm_provider": "wandb",
"mode": "chat",
"supports_reasoning": true,
"input_cost_per_token": 0.00000131,
"output_cost_per_token": 0.00000396,
"cache_read_input_token_cost": 0.000000044,
"input_cost_per_token": 1.31e-06,
"output_cost_per_token": 3.96e-06,
"cache_read_input_token_cost": 4.4e-08,
"supports_prompt_caching": true,
"source": "https://wandb.ai/site/pricing/tokens/"
},
@ -59108,9 +59123,9 @@
"litellm_provider": "wandb",
"mode": "chat",
"supports_reasoning": true,
"input_cost_per_token": 0.0000001,
"output_cost_per_token": 0.00000015,
"cache_read_input_token_cost": 0.00000005,
"input_cost_per_token": 1e-07,
"output_cost_per_token": 1.5e-07,
"cache_read_input_token_cost": 5e-08,
"supports_prompt_caching": true,
"source": "https://wandb.ai/site/pricing/tokens/"
},

View file

@ -10,8 +10,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.realtime_streaming import (
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
@ -20,10 +18,6 @@ from litellm.litellm_core_utils.realtime_streaming import (
)
from litellm.llms.xai.realtime.transformation import XAIRealtimeNormalizer
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.llms.openai import (
OpenAIRealtimeStreamResponseBaseObject,
OpenAIRealtimeStreamSessionEvents,
)
def _make_transcript_event(text: str, item_id: str = "item_x") -> bytes:
@ -161,6 +155,7 @@ async def test_backend_to_client_send_text_receives_str_not_bytes():
logging_obj = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
@ -812,7 +807,6 @@ async def test_transcription_captured_in_backend_to_client():
Test that conversation.item.input_audio_transcription.completed events
from the backend are captured as user input during the WebSocket session.
"""
import litellm
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
@ -838,6 +832,7 @@ async def test_transcription_captured_in_backend_to_client():
logging_obj.model_call_details = {"messages": "default-message-value"}
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
@ -883,6 +878,7 @@ async def test_transcription_session_captures_usage_and_skips_response_create():
logging_obj.model_call_details = {}
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
@ -1100,7 +1096,6 @@ def test_capture_transcription_usage_deduplicates_when_already_stored():
When the event is already in messages (logged via store_message), it must not
be appended a second time by _capture_transcription_usage.
"""
import litellm
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
# Add the event type to the default logged list so _should_store_message returns True.
@ -1409,7 +1404,6 @@ async def test_realtime_guardrail_blocks_prompt_injection(monkeypatch: pytest.Mo
)
@pytest.mark.asyncio
async def test_realtime_guardrail_allows_clean_transcript(monkeypatch: pytest.MonkeyPatch):
"""
@ -1466,7 +1460,6 @@ async def test_realtime_guardrail_allows_clean_transcript(monkeypatch: pytest.Mo
assert len(response_creates) == 1, f"Clean transcript should trigger response.create, got: {sent_to_backend}"
@pytest.mark.asyncio
async def test_realtime_text_input_guardrail_blocks_and_returns_error(monkeypatch: pytest.MonkeyPatch):
"""
@ -1560,7 +1553,6 @@ async def test_realtime_text_input_guardrail_blocks_and_returns_error(monkeypatc
assert len(original_items) == 0, f"Blocked item should not be forwarded to backend, got: {original_items}"
@pytest.mark.asyncio
async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(monkeypatch: pytest.MonkeyPatch):
"""
@ -1649,7 +1641,6 @@ async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(
assert "test@example.com" not in sanitized_item["output"]
@pytest.mark.asyncio
async def test_realtime_function_call_output_guardrail_allows_clean_output(monkeypatch: pytest.MonkeyPatch):
"""
@ -1714,7 +1705,6 @@ async def test_realtime_function_call_output_guardrail_allows_clean_output(monke
assert len(forwarded) == 1, f"Clean function_call_output should be forwarded, got: {forwarded}"
@pytest.mark.asyncio
async def test_realtime_text_input_guardrail_uses_pre_call_mode(monkeypatch: pytest.MonkeyPatch):
"""
@ -1750,7 +1740,6 @@ async def test_realtime_text_input_guardrail_uses_pre_call_mode(monkeypatch: pyt
)
@pytest.mark.asyncio
async def test_realtime_session_created_injects_session_update_for_audio_guardrail(monkeypatch: pytest.MonkeyPatch):
"""
@ -1807,7 +1796,6 @@ async def test_realtime_session_created_injects_session_update_for_audio_guardra
)
@pytest.mark.asyncio
async def test_realtime_session_created_does_not_inject_session_update_for_pre_call_only(
monkeypatch: pytest.MonkeyPatch,
@ -1852,7 +1840,6 @@ async def test_realtime_session_created_does_not_inject_session_update_for_pre_c
assert len(session_updates) == 0, f"pre_call-only guardrail must not inject session.update, got: {sent_to_backend}"
@pytest.mark.asyncio
async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monkeypatch: pytest.MonkeyPatch):
"""Model Armor-style pre_call + post_call must not gate audio VAD."""
@ -1868,17 +1855,17 @@ async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monke
litellm,
"callbacks",
[
ModelArmorStyleGuardrail(
guardrail_name="model_armor_all_pre_call",
event_hook=GuardrailEventHooks.pre_call,
default_on=False,
),
ModelArmorStyleGuardrail(
guardrail_name="model_armor_all_post_call",
event_hook=GuardrailEventHooks.post_call,
default_on=False,
),
],
ModelArmorStyleGuardrail(
guardrail_name="model_armor_all_pre_call",
event_hook=GuardrailEventHooks.pre_call,
default_on=False,
),
ModelArmorStyleGuardrail(
guardrail_name="model_armor_all_post_call",
event_hook=GuardrailEventHooks.post_call,
default_on=False,
),
],
)
client_ws = MagicMock()
@ -1902,7 +1889,6 @@ async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monke
assert streaming._has_audio_transcription_guardrails() is False
@pytest.mark.asyncio
async def test_end_session_after_n_fails_closes_connection(monkeypatch: pytest.MonkeyPatch):
"""
@ -1949,7 +1935,6 @@ async def test_end_session_after_n_fails_closes_connection(monkeypatch: pytest.M
assert streaming._violation_count == 2
@pytest.mark.asyncio
async def test_on_violation_end_session_closes_on_first_fail(monkeypatch: pytest.MonkeyPatch):
"""
@ -1995,7 +1980,6 @@ async def test_on_violation_end_session_closes_on_first_fail(monkeypatch: pytest
assert streaming._violation_count == 1
@pytest.mark.asyncio
async def test_provider_path_suppresses_duplicate_session_created_after_synthetic():
client_ws = MagicMock()
@ -2956,7 +2940,9 @@ async def test_log_messages_routes_async_logging_through_bounded_worker():
mock_worker.ensure_initialized_and_enqueue.assert_called_once()
enqueued = mock_worker.ensure_initialized_and_enqueue.call_args
assert (enqueued.args or tuple(enqueued.kwargs.values()))[0] is logging_obj.dispatch_success_handlers.return_value
assert (enqueued.args or tuple(enqueued.kwargs.values()))[
0
] is logging_obj.dispatch_success_handlers.return_value
logging_obj.dispatch_success_handlers.assert_called_once_with(streaming.messages, prefer_async_handlers=True)
logging_obj.success_handler.assert_not_called()
# the bare create_task path must no longer be used for success logging
@ -3041,6 +3027,7 @@ async def test_session_close_flushes_unbilled_transcription_usage():
logging_obj: Final = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
"type": "tokens",
@ -3116,6 +3103,7 @@ async def test_session_close_flush_noop_without_unbilled_usage():
logging_obj: Final = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
provider_config: Final = MagicMock()
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=None)
@ -3412,3 +3400,144 @@ async def test_refused_session_does_not_stamp_the_reservation_ownership_marker()
assert session.logging.logged_failures == (upstream_close,)
assert REALTIME_SESSION_SUCCESS_LOGGED_KEY not in session.logging.model_call_details
@pytest.mark.asyncio
async def test_separate_usage_provider_flushes_duration_once_without_client_event():
from typing import Final
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws: Final = MagicMock()
backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None))
logging_obj: Final = MagicMock()
logging_obj.model_call_details = {}
logging_obj.dispatch_success_handlers = AsyncMock()
usage_provider: Final = MagicMock()
usage_provider.unbilled_usage_on_session_close.return_value = {
"type": "duration",
"seconds": 0.75,
}
streaming: Final = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
model="muse-voice-transcribe-1.0",
usage_provider=usage_provider,
)
await streaming.backend_to_client_send_messages()
usage_provider.unbilled_usage_on_session_close.assert_called_once_with("muse-voice-transcribe-1.0")
duration_events: Final = tuple(
message
for message in streaming.messages
if isinstance(message, dict) and message.get("usage") == {"type": "duration", "seconds": 0.75}
)
assert len(duration_events) == 1
assert client_ws.send_text.await_count == 0
logging_obj.dispatch_success_handlers.assert_called_once_with(streaming.messages, prefer_async_handlers=True)
@pytest.mark.asyncio
async def test_transformed_transcription_completion_never_sends_response_create():
from typing import Final
completed_event: Final = {
"type": "conversation.item.input_audio_transcription.completed",
"event_id": "event_1",
"item_id": "turn_1",
"content_index": 0,
"transcript": "private transcript",
"usage": {"type": "duration", "seconds": 0.5},
}
provider_config: Final = MagicMock()
provider_config.requires_session_configuration.return_value = True
provider_config.transform_realtime_response.return_value = {
"response": completed_event,
"current_output_item_id": None,
"current_response_id": None,
"current_delta_chunks": None,
"current_conversation_id": None,
"current_item_chunks": None,
"current_delta_type": None,
"session_configuration_request": None,
}
provider_config.transform_realtime_request.return_value = (json.dumps({"type": "response.create"}),)
provider_config.is_setup_message.return_value = False
provider_config.is_content_message.return_value = False
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws: Final = MagicMock()
backend_ws.send = AsyncMock()
streaming: Final = RealTimeStreaming(
client_ws,
backend_ws,
MagicMock(),
provider_config=provider_config,
model="muse-voice-transcribe-1.0",
force_transcription_model="muse-voice-transcribe-1.0",
)
await streaming._handle_provider_config_message("{}")
assert json.loads(client_ws.send_text.await_args.args[0]) == completed_event
backend_ws.send.assert_not_awaited()
def test_private_logging_excludes_audio_transcript_hints_and_provider_body(monkeypatch: pytest.MonkeyPatch):
from typing import Final
monkeypatch.setattr(litellm, "logged_real_time_event_types", "*")
logging_obj: Final = MagicMock()
logging_obj.model_call_details = {}
streaming: Final = RealTimeStreaming(
MagicMock(),
MagicMock(),
logging_obj,
model="muse-voice-transcribe-1.0",
exclude_private_content_from_logs=True,
)
audio: Final = "cHJpdmF0ZS1hdWRpbw=="
transcript: Final = "private transcript"
keyword: Final = "private keyword"
provider_body: Final = "private provider body"
streaming.store_input(
json.dumps(
{
"type": "session.update",
"session": {
"type": "transcription",
"model": "muse-voice-transcribe-1.0",
"mode": "ENDPOINTING",
"audio": {"input": {"transcription": {"keywords": [keyword]}}},
},
}
)
)
streaming.store_input(json.dumps({"type": "input_audio_buffer.append", "audio": audio}))
streaming.store_message(
{
"type": "conversation.item.input_audio_transcription.completed",
"event_id": "event_1",
"item_id": "turn_1",
"transcript": transcript,
"provider_body": provider_body,
"usage": {"type": "duration", "seconds": 1.0},
}
)
logged_inputs: Final = tuple(call.kwargs["input"] for call in logging_obj.pre_call.call_args_list)
serialized: Final = json.dumps({"inputs": logged_inputs, "messages": streaming.messages})
assert audio not in serialized
assert transcript not in serialized
assert keyword not in serialized
assert provider_body not in serialized
assert "muse-voice-transcribe-1.0" in serialized
assert "ENDPOINTING" in serialized
assert "turn_1" in serialized
assert '"seconds": 1.0' in serialized
assert streaming.input_messages == []

View file

@ -0,0 +1,449 @@
import asyncio
import base64
import json
from collections.abc import Callable
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.llms.meta.realtime.handler import (
DEFAULT_MUSE_REALTIME_URL,
MetaRealtime,
MuseAdapterError,
MuseRealtimeAdapter,
build_muse_realtime_url,
normalize_access_token,
safe_close_reason,
sanitize_close_code,
)
from litellm.llms.meta.realtime.transformation import MUSE_MODEL
class FakeProviderWebSocket:
def __init__(self, session_id: str = "provider-session") -> None:
self.sent: list[str | bytes] = []
self.close_calls: list[tuple[int, str]] = []
self._session_id: Final = session_id
self._recv_count = 0
self._closed = asyncio.Event()
async def send(self, message: str | bytes) -> None:
self.sent.append(message)
async def recv(self, decode: bool | None = None) -> str | bytes:
self._recv_count += 1
if self._recv_count == 1:
return json.dumps({"sessionId": self._session_id})
await self._closed.wait()
raise MuseAdapterError("closed", close_code=1000)
async def close(self, code: int = 1000, reason: str = "") -> None:
self.close_calls.append((code, reason))
self._closed.set()
class DelayedAckWebSocket(FakeProviderWebSocket):
def __init__(self) -> None:
super().__init__()
self.ack_release = asyncio.Event()
async def recv(self, decode: bool | None = None) -> str | bytes:
self._recv_count += 1
if self._recv_count == 1:
await self.ack_release.wait()
return json.dumps({"sessionId": self._session_id})
await self._closed.wait()
raise MuseAdapterError("closed", close_code=1000)
async def _wait_until(predicate: Callable[[], bool]) -> None:
for _ in range(100):
if predicate():
return
await asyncio.sleep(0)
raise AssertionError("condition did not become true")
def _session_update(*, rate: int = 24_000, mode: str = "ENDPOINTING") -> str:
return json.dumps(
{
"type": "session.update",
"session": {
"type": "transcription",
"mode": mode,
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": rate, "channels": 1},
"transcription": {"model": MUSE_MODEL},
}
},
},
}
)
async def _configured_adapter(
*,
rate: int = 24_000,
mode: str = "ENDPOINTING",
provider_ws: FakeProviderWebSocket | None = None,
monotonic: Callable[[], float] = lambda: 10.0,
sleep: Callable[[float], object] | None = None,
) -> tuple[MuseRealtimeAdapter, FakeProviderWebSocket, dict[str, object]]:
ws: Final = provider_ws or FakeProviderWebSocket()
connect_call: Final[dict[str, object]] = {}
async def connect(url: str, **kwargs: object) -> FakeProviderWebSocket:
connect_call.update({"url": url, **kwargs})
return ws
async def no_sleep(_: float) -> None:
return None
adapter: Final = MuseRealtimeAdapter(
model=f"meta/{MUSE_MODEL}",
api_key=" raw-token ",
websocket_connect=connect,
monotonic=monotonic,
sleep=sleep or no_sleep,
)
created: Final = json.loads(await adapter.recv())
assert created["type"] == "session.created"
await adapter.send(_session_update(rate=rate, mode=mode))
updated: Final = json.loads(await adapter.recv())
assert updated["type"] == "session.updated"
return adapter, ws, connect_call
@pytest.mark.parametrize(
("api_key", "expected"),
[
("token", "Bearer token"),
(" Bearer token ", "Bearer token"),
("bearer token", "Bearer token"),
],
)
def test_normalize_access_token_emits_exactly_one_bearer_prefix(api_key: str, expected: str):
assert normalize_access_token(api_key) == expected
@pytest.mark.parametrize("api_key", ["", " ", "Bearer", " bearer "])
def test_normalize_access_token_rejects_missing_token(api_key: str):
with pytest.raises(ValueError, match=r"token|key is required"):
normalize_access_token(api_key)
def test_build_muse_realtime_url_uses_fixed_secure_path():
assert build_muse_realtime_url(None) == DEFAULT_MUSE_REALTIME_URL
assert build_muse_realtime_url("https://example.test/custom/path?ignored=yes") == (
"wss://example.test/v1/asr/realtime"
)
assert build_muse_realtime_url("wss://example.test:8443/other") == ("wss://example.test:8443/v1/asr/realtime")
@pytest.mark.parametrize(
"api_base",
[
"http://example.test",
"ws://example.test",
"wss://user:pass@example.test",
"wss://example.test/path#fragment",
"not-a-url",
],
)
def test_build_muse_realtime_url_rejects_insecure_or_ambiguous_overrides(api_base: str):
with pytest.raises(ValueError, match="absolute wss:// or https://"):
build_muse_realtime_url(api_base)
@pytest.mark.asyncio
async def test_handshake_contains_bearer_only_in_json_body_and_waits_for_ack():
provider_ws: Final = DelayedAckWebSocket()
connect_call: Final[dict[str, object]] = {}
async def connect(url: str, **kwargs: object) -> DelayedAckWebSocket:
connect_call.update({"url": url, **kwargs})
return provider_ws
adapter: Final = MuseRealtimeAdapter(
model=MUSE_MODEL,
api_key="Bearer private-token",
websocket_connect=connect,
)
await adapter.recv()
update_task: Final = asyncio.create_task(adapter.send(_session_update()))
await _wait_until(lambda: len(provider_ws.sent) == 1)
assert connect_call["url"] == DEFAULT_MUSE_REALTIME_URL
assert "additional_headers" not in connect_call
handshake: Final = json.loads(provider_ws.sent[0])
assert handshake["authorization"] == {"accessToken": "Bearer private-token"}
assert handshake["audioEncoding"] == "PCM_24KHZ"
assert not update_task.done()
assert not any(isinstance(frame, bytes) for frame in provider_ws.sent)
provider_ws.ack_release.set()
await update_task
updated: Final = json.loads(await adapter.recv())
assert updated["type"] == "session.updated"
assert updated["session"]["id"] == "provider-session"
await adapter.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(("rate", "packet_bytes"), [(16_000, 2_560), (24_000, 3_840)])
async def test_audio_is_strictly_decoded_and_packetized_as_raw_pcm(rate: int, packet_bytes: int):
adapter, provider_ws, _ = await _configured_adapter(rate=rate)
pcm: Final = (b"\xff\xfe\x00\x80" * (packet_bytes // 2))[: packet_bytes * 2]
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(pcm).decode()}))
await _wait_until(lambda: sum(isinstance(frame, bytes) for frame in provider_ws.sent) == 2)
binary_frames: Final = tuple(frame for frame in provider_ws.sent if isinstance(frame, bytes))
assert binary_frames == (pcm[:packet_bytes], pcm[packet_bytes:])
assert b"\xff\xfe" in pcm
await adapter.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("audio", "expected_message"),
[
("not base64!", "valid base64"),
(base64.b64encode(b"\x00").decode(), "complete samples"),
],
)
async def test_invalid_base64_or_odd_pcm_is_rejected(audio: str, expected_message: str):
adapter, provider_ws, _ = await _configured_adapter()
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": audio}))
error: Final = json.loads(await adapter.recv())
assert error["type"] == "error"
assert error["error"]["code"] == "invalid_audio"
assert expected_message in error["error"]["message"]
assert adapter.close_code == 1008
assert not any(isinstance(frame, bytes) for frame in provider_ws.sent)
await adapter.close()
@pytest.mark.asyncio
async def test_absolute_pacing_delays_only_audio_ahead_of_wall_time():
sleeps: Final[list[float]] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
adapter, provider_ws, _ = await _configured_adapter(monotonic=lambda: 10.0, sleep=record_sleep)
pcm: Final = b"\x01\x02" * 3_840
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(pcm).decode()}))
await _wait_until(lambda: sum(isinstance(frame, bytes) for frame in provider_ws.sent) == 2)
assert sleeps == pytest.approx([0.08])
await adapter.close()
@pytest.mark.asyncio
async def test_append_larger_than_four_seconds_is_rejected_without_dropping_prefix():
adapter, provider_ws, _ = await _configured_adapter(rate=16_000)
oversized_pcm: Final = b"\x00\x00" * (16_000 * 4 + 1)
await adapter.send(
json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(oversized_pcm).decode()})
)
error: Final = json.loads(await adapter.recv())
assert error["error"]["code"] == "audio_backlog_exceeded"
assert adapter.close_code == 1008
assert not any(isinstance(frame, bytes) for frame in provider_ws.sent)
await adapter.close()
@pytest.mark.asyncio
async def test_clear_discards_only_unsent_audio():
adapter, provider_ws, _ = await _configured_adapter()
old_pcm: Final = b"\x01\x02" * 100
new_pcm: Final = b"\x03\x04" * 100
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(old_pcm).decode()}))
await adapter.send(_event("input_audio_buffer.clear"))
cleared: Final = json.loads(await adapter.recv())
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(new_pcm).decode()}))
await adapter.send(_event("input_audio_buffer.commit"))
await _wait_until(lambda: any(isinstance(frame, bytes) for frame in provider_ws.sent))
assert cleared["type"] == "input_audio_buffer.cleared"
assert tuple(frame for frame in provider_ws.sent if isinstance(frame, bytes)) == (new_pcm,)
assert '{"type":"endStream"}' not in provider_ws.sent
await adapter.close()
@pytest.mark.asyncio
async def test_endpointing_commit_flushes_partial_packet_without_ending_stream():
adapter, provider_ws, _ = await _configured_adapter(mode="ENDPOINTING")
pcm: Final = b"\x01\x02" * 100
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(pcm).decode()}))
await adapter.send(_event("input_audio_buffer.commit"))
committed: Final = json.loads(await adapter.recv())
await _wait_until(lambda: any(isinstance(frame, bytes) for frame in provider_ws.sent))
assert committed["type"] == "input_audio_buffer.committed"
assert committed["item_id"].startswith("item_")
assert committed["previous_item_id"] is None
assert tuple(frame for frame in provider_ws.sent if isinstance(frame, bytes)) == (pcm,)
assert '{"type":"endStream"}' not in provider_ws.sent
await adapter.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("mode", "terminal_event"),
[("PUSH_TO_TALK", "input_audio_buffer.commit"), ("ENDPOINTING", "input_audio_buffer.end")],
)
async def test_commit_or_end_sends_end_stream_exactly_once(mode: str, terminal_event: str):
adapter, provider_ws, _ = await _configured_adapter(mode=mode)
pcm: Final = b"\x01\x02" * 100
await adapter.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(pcm).decode()}))
await adapter.send(_event(terminal_event))
await adapter.send(_event("input_audio_buffer.end"))
await _wait_until(lambda: '{"type":"endStream"}' in provider_ws.sent)
assert tuple(frame for frame in provider_ws.sent if isinstance(frame, bytes)) == (pcm,)
assert provider_ws.sent.count('{"type":"endStream"}') == 1
await adapter.close()
@pytest.mark.asyncio
async def test_response_create_is_returned_as_error_and_never_sent_upstream():
adapter, provider_ws, _ = await _configured_adapter()
await adapter.send(_event("response.create"))
error: Final = json.loads(await adapter.recv())
assert error["type"] == "error"
assert error["error"]["code"] == "unsupported_event"
assert not any(isinstance(frame, str) and "response.create" in frame for frame in provider_ws.sent)
await adapter.close()
@pytest.mark.asyncio
async def test_close_codes_and_reasons_are_sanitized_without_secret_leakage():
adapter, provider_ws, _ = await _configured_adapter()
secret: Final = "Bearer private-token"
await adapter.close(code=4001, reason=f"provider rejected {secret}")
assert adapter.close_code == 1011
assert adapter.close_reason == "Realtime transcription service error"
assert provider_ws.close_calls == [(1011, "Realtime transcription service error")]
assert secret not in json.dumps(provider_ws.close_calls)
assert sanitize_close_code(1013) == 1013
assert safe_close_reason(1008) == "Invalid realtime transcription request"
@pytest.mark.asyncio
async def test_handshake_failure_reports_only_exception_type():
secret: Final = "private-token"
async def failing_connect(url: str, **kwargs: object) -> FakeProviderWebSocket:
raise RuntimeError(f"failed with {secret}")
adapter: Final = MuseRealtimeAdapter(
model=MUSE_MODEL,
api_key=secret,
websocket_connect=failing_connect,
)
await adapter.recv()
await adapter.send(_session_update())
error = json.loads(await adapter.recv())
assert error["type"] == "error"
assert error["error"]["message"] == "Meta Muse realtime handshake failed"
assert secret not in json.dumps(error)
with pytest.raises(MuseAdapterError) as exc_info:
await adapter.recv()
assert exc_info.value.close_code == 1011
assert secret not in str(exc_info.value)
@pytest.mark.asyncio
async def test_meta_realtime_missing_credentials_closes_client_with_policy_code():
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
client_ws.close = AsyncMock()
await MetaRealtime().async_realtime(
model=MUSE_MODEL,
websocket=client_ws,
logging_obj=MagicMock(),
api_key=None,
)
client_ws.close.assert_awaited_once_with(
code=1008,
reason="Invalid realtime transcription request",
)
sent_error: Final = json.loads(client_ws.send_text.await_args.args[0])
assert sent_error["type"] == "error"
assert sent_error["error"]["code"] == "invalid_configuration"
@pytest.mark.asyncio
async def test_meta_realtime_invalid_constructor_input_sends_error_before_close():
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
client_ws.close = AsyncMock()
await MetaRealtime().async_realtime(
model=MUSE_MODEL,
websocket=client_ws,
logging_obj=MagicMock(),
api_key="Bearer",
)
sent_error: Final = json.loads(client_ws.send_text.await_args.args[0])
assert sent_error["error"]["message"] == "Invalid Meta Muse realtime configuration"
client_ws.close.assert_awaited_once_with(
code=1008,
reason="Invalid realtime transcription request",
)
@pytest.mark.asyncio
async def test_meta_realtime_enables_private_logging_usage_and_model_enforcement(monkeypatch: pytest.MonkeyPatch):
captured: Final[dict[str, object]] = {}
class CapturingStreaming:
def __init__(self, websocket, backend_ws, logging_obj, **kwargs):
captured.update({"websocket": websocket, "backend_ws": backend_ws, "logging_obj": logging_obj, **kwargs})
async def bidirectional_forward(self) -> None:
return None
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
client_ws.close = AsyncMock()
monkeypatch.setattr("litellm.llms.meta.realtime.handler.RealTimeStreaming", CapturingStreaming)
await MetaRealtime().async_realtime(
model=MUSE_MODEL,
websocket=client_ws,
logging_obj=MagicMock(),
api_key="private-token",
)
adapter: Final = captured["backend_ws"]
assert isinstance(adapter, MuseRealtimeAdapter)
assert captured["force_transcription_model"] == MUSE_MODEL
assert captured["usage_provider"] is adapter
assert captured["exclude_private_content_from_logs"] is True
client_ws.close.assert_awaited_once_with(code=1000, reason="Session closed")
def _event(event_type: str) -> str:
return json.dumps({"type": event_type})

View file

@ -0,0 +1,299 @@
import json
import pytest
from litellm.llms.meta.realtime.transformation import (
MUSE_MODEL,
MuseEventTransformer,
MuseProtocolError,
encode_event,
normalize_language,
parse_session_update,
session_created_event,
session_updated_event,
)
def _event(event_type: str, **fields: object) -> str:
return json.dumps({"type": event_type, **fields})
def test_beta_session_builds_authenticated_24khz_handshake_with_hints():
config = parse_session_update(
_event(
"session.update",
session={
"type": "transcription",
"input_audio_format": "pcm16",
"turn_detection": {"type": "server_vad"},
"input_audio_transcription": {
"model": "meta/muse-voice-transcribe-1.0",
"language": "en-US",
"language_bias": ["Spanish", "english", "French"],
"keywords": [" Muse ", "LiteLLM", "Muse"],
"prompt": "must not become a keyword",
},
},
),
"meta/muse-voice-transcribe-1.0",
)
assert config.sample_rate == 24_000
assert config.packet_bytes == 3_840
assert config.mode == "ENDPOINTING"
assert config.language_bias == ("English", "Spanish", "French")
assert config.keywords == ("Muse", "LiteLLM")
assert config.handshake("Bearer token") == {
"mode": "ENDPOINTING",
"authorization": {"accessToken": "Bearer token"},
"audioEncoding": "PCM_24KHZ",
"model": MUSE_MODEL,
"partialMode": "CUMULATIVE",
"emitAudioProgress": True,
"keywords": ["Muse", "LiteLLM"],
"languageBias": ["English", "Spanish", "French"],
}
assert "must not become a keyword" not in json.dumps(config.handshake("Bearer token"))
def test_ga_session_accepts_16khz_mono_push_to_talk():
config = parse_session_update(
_event(
"session.update",
session={
"type": "transcription",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 16000, "channels": 1},
"turn_detection": None,
"transcription": {"model": MUSE_MODEL, "language": "zh-Hans"},
}
},
},
),
MUSE_MODEL,
)
assert config.sample_rate == 16_000
assert config.packet_bytes == 2_560
assert config.mode == "PUSH_TO_TALK"
assert config.language_bias == ("Mandarin Chinese",)
assert config.handshake("Bearer token")["audioEncoding"] == "PCM_16KHZ"
@pytest.mark.parametrize(
("source", "expected"),
[
("EN_us", "English"),
("mandarin chinese", "Mandarin Chinese"),
("fil-PH", "Tagalog"),
("iw-IL", "Hebrew"),
("pt-BR", "Portuguese"),
],
)
def test_language_normalization_uses_official_muse_names(source: str, expected: str):
assert normalize_language(source) == expected
@pytest.mark.parametrize(
("session", "message"),
[
({"input_audio_format": "g711_ulaw"}, "requires pcm16"),
({"audio": {"input": {"format": {"type": "audio/pcm", "rate": 8000}}}}, "16000 Hz or 24000 Hz"),
(
{"audio": {"input": {"format": {"type": "audio/pcm", "rate": 24000, "channels": 2}}}},
"requires mono",
),
(
{"input_audio_format": "pcm16", "audio": {"input": {"format": {"type": "audio/pcm"}}}},
"either beta or GA layout",
),
({"input_audio_transcription": {"model": "other-model"}}, "cannot be changed"),
({"input_audio_transcription": {"keywords": ["valid", ""]}}, "non-empty strings"),
({"input_audio_transcription": {"language": "xx"}}, "unsupported Muse Voice language"),
],
)
def test_session_rejects_unsupported_audio_model_and_hints(session: dict[str, object], message: str):
with pytest.raises(MuseProtocolError, match=message):
parse_session_update(_event("session.update", session={"type": "transcription", **session}), MUSE_MODEL)
def test_session_events_expose_openai_transcription_shapes():
config = parse_session_update(
_event(
"session.update",
session={
"mode": "DIARIZATION",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 24000},
"transcription": {"model": MUSE_MODEL, "language": "ja", "keywords": ["Meta"]},
}
},
},
),
MUSE_MODEL,
)
created = session_created_event(MUSE_MODEL, "session-before-handshake")
updated = session_updated_event(config, "provider-session")
assert created["type"] == "session.created"
assert created["session"]["type"] == "transcription"
assert updated["type"] == "session.updated"
assert updated["session"]["id"] == "provider-session"
assert updated["session"]["audio"]["input"]["transcription"] == {
"model": MUSE_MODEL,
"language": "Japanese",
"keywords": ["Meta"],
"language_bias": ["Japanese"],
}
def test_turnless_empty_silence_transcript_is_ignored():
transformer = MuseEventTransformer()
assert transformer.transform(_event("transcript", transcript="", final=True)) == ()
def test_transcript_without_speech_start_synthesizes_start_before_delta():
transformer = MuseEventTransformer()
events = transformer.transform(_event("transcript", turnId="turn-1", transcript="hello", final=False))
assert [event["type"] for event in events] == [
"input_audio_buffer.speech_started",
"conversation.item.input_audio_transcription.delta",
]
def test_cumulative_partials_emit_only_extensions_and_final_is_authoritative():
transformer = MuseEventTransformer()
started = transformer.transform(_event("speechStart", turnId="turn-1"))
first = transformer.transform(_event("transcript", turnId="turn-1", transcript="hello", final=False))
extension = transformer.transform(_event("transcript", turnId="turn-1", transcript="hello world", final=False))
rewrite = transformer.transform(_event("transcript", turnId="turn-1", transcript="hullo world", final=False))
assert transformer.transform(_event("speechComplete", turnId="turn-1", transcript="hullo world")) == ()
completed = transformer.transform(_event("speechEnd", turnId="turn-1"))
assert [event["type"] for event in started] == ["input_audio_buffer.speech_started"]
assert first[0]["delta"] == "hello"
assert extension[0]["delta"] == " world"
assert rewrite == ()
assert completed[0]["type"] == "input_audio_buffer.speech_stopped"
assert completed[1]["type"] == "conversation.item.input_audio_transcription.completed"
assert completed[1]["item_id"] == "turn-1"
assert completed[1]["transcript"] == "hullo world"
def test_completed_transcript_waits_for_speech_stopped():
transformer = MuseEventTransformer()
transformer.transform(_event("speechStart", turnId="turn-1"))
assert transformer.transform(_event("speechComplete", turnId="turn-1", transcript="done")) == ()
released = transformer.transform(_event("speechEnd", turnId="turn-1"))
assert [event["type"] for event in released] == [
"input_audio_buffer.speech_stopped",
"conversation.item.input_audio_transcription.completed",
]
def test_overlapping_turns_are_emitted_in_provider_turn_order():
transformer = MuseEventTransformer()
transformer.transform(_event("speechStart", turnId="turn-a"))
transformer.transform(_event("speechStart", turnId="turn-b"))
assert transformer.transform(_event("transcript", turnId="turn-b", transcript="second", final=False)) == ()
assert transformer.transform(_event("speechComplete", turnId="turn-a", transcript="first")) == ()
released = transformer.transform(_event("speechEnd", turnId="turn-a"))
assert [(event["type"], event["item_id"]) for event in released] == [
("input_audio_buffer.speech_stopped", "turn-a"),
("conversation.item.input_audio_transcription.completed", "turn-a"),
("input_audio_buffer.speech_started", "turn-b"),
("conversation.item.input_audio_transcription.delta", "turn-b"),
]
assert transformer.transform(_event("speechComplete", turnId="turn-b", transcript="second final")) == ()
final_b = transformer.transform(_event("speechEnd", turnId="turn-b"))
assert final_b[0]["type"] == "input_audio_buffer.speech_stopped"
assert final_b[1]["item_id"] == "turn-b"
assert final_b[1]["transcript"] == "second final"
def test_committed_item_id_is_used_for_next_provider_turn():
transformer = MuseEventTransformer()
previous_item_id, item_id = transformer.commit_item()
started = transformer.transform(_event("speechStart", turnId="provider-turn"))
transformer.transform(_event("speechComplete", turnId="provider-turn", transcript="hello"))
completed = transformer.transform(_event("speechEnd", turnId="provider-turn"))
assert previous_item_id is None
assert started[0]["item_id"] == item_id
assert completed[-1]["item_id"] == item_id
def test_commit_after_speech_start_reuses_active_item_id():
transformer = MuseEventTransformer()
started = transformer.transform(_event("speechStart", turnId="provider-turn"))
previous_item_id, item_id = transformer.commit_item()
transformer.transform(_event("speechComplete", turnId="provider-turn", transcript="hello"))
completed = transformer.transform(_event("speechEnd", turnId="provider-turn"))
assert previous_item_id is None
assert item_id == "provider-turn"
assert started[0]["item_id"] == item_id
assert completed[-1]["item_id"] == item_id
def test_speaker_and_positive_audio_progress_deltas_attach_to_next_completion():
transformer = MuseEventTransformer()
transformer.transform(_event("audioProgress", audioProcessedMs=1000))
transformer.transform(_event("audioProgress", audioProcessedMs=750))
transformer.transform(_event("audioProgress", audioProcessedMs=1600))
transformer.transform(_event("speaker", turnId=42, label=" Speaker 2 "))
transformer.transform(_event("speechComplete", turnId=42, transcript="hello"))
completed = transformer.transform(_event("speechEnd", turnId=42))
assert completed[-1]["speaker"] == "Speaker 2"
assert completed[-1]["usage"] == {"type": "duration", "seconds": 1.6}
assert transformer.take_unbilled_usage() is None
def test_trailing_audio_progress_is_returned_once():
transformer = MuseEventTransformer()
transformer.transform(_event("audioProgress", audioProcessedMs=250))
assert transformer.take_unbilled_usage() == {"type": "duration", "seconds": 0.25}
assert transformer.take_unbilled_usage() is None
def test_completed_turn_tombstone_suppresses_late_duplicates():
transformer = MuseEventTransformer()
transformer.transform(_event("speechComplete", turnId="turn-1", transcript="done"))
assert transformer.transform(_event("speechComplete", turnId="turn-1", transcript="duplicate")) == ()
assert transformer.transform(_event("speaker", turnId="turn-1", label="late")) == ()
def test_provider_error_is_sanitized_and_encodable():
token = "private-token"
provider_body = f"authorization failed for Bearer {token}"
transformed = MuseEventTransformer().transform(
_event("error", code="AUTH", message=provider_body, request={"accessToken": token})
)
encoded = encode_event(transformed[0])
assert json.loads(encoded)["error"] == {
"type": "server_error",
"code": "provider_error",
"message": "Meta Muse realtime transcription failed",
}
assert token not in encoded
assert provider_body not in encoded

View file

@ -4,7 +4,6 @@ from types import TracebackType
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
import litellm
@ -152,6 +151,85 @@ async def test_vertex_credential_resolution_bounds_a_thread_offloaded_refresh():
assert time.monotonic() - start < 5
@pytest.mark.asyncio
async def test_meta_realtime_rejects_missing_transcription_intent(monkeypatch: pytest.MonkeyPatch):
def mock_get_llm_provider(model, api_base, api_key):
return model.removeprefix("meta/"), "meta", api_key, api_base
monkeypatch.setattr(realtime_main, "get_llm_provider", mock_get_llm_provider)
with pytest.raises(ValueError, match="requires intent=transcription"):
await realtime_main._arealtime.__wrapped__(
model="meta/muse-voice-transcribe-1.0",
websocket=MagicMock(),
litellm_logging_obj=FakeLogging(),
query_params={"model": "meta/muse-voice-transcribe-1.0"},
)
@pytest.mark.asyncio
async def test_meta_realtime_rejects_unsupported_model_before_connecting(monkeypatch: pytest.MonkeyPatch):
def mock_get_llm_provider(model, api_base, api_key):
return model.removeprefix("meta/"), "meta", api_key, api_base
monkeypatch.setattr(realtime_main, "get_llm_provider", mock_get_llm_provider)
with pytest.raises(ValueError, match="Unsupported Meta realtime model: other-model"):
await realtime_main._arealtime.__wrapped__(
model="meta/other-model",
websocket=MagicMock(),
litellm_logging_obj=FakeLogging(),
query_params={"model": "meta/other-model", "intent": "transcription"},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("explicit_key", "model_key", "meta_key", "expected"),
[
("explicit", "model-env", "meta-env", "explicit"),
(None, "model-env", "meta-env", "model-env"),
(None, None, "meta-env", "meta-env"),
],
)
async def test_meta_realtime_credential_precedence_is_forwarded_to_handler(
monkeypatch: pytest.MonkeyPatch,
explicit_key: str | None,
model_key: str | None,
meta_key: str | None,
expected: str,
):
captured: dict[str, object] = {}
def mock_get_llm_provider(model, api_base, api_key):
return model.removeprefix("meta/"), "meta", meta_key, api_base
def mock_get_secret_str(name: str):
return {"MODEL_API_KEY": model_key, "META_API_KEY": meta_key}.get(name)
async def mock_async_realtime(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr(realtime_main, "get_llm_provider", mock_get_llm_provider)
monkeypatch.setattr(realtime_main, "get_secret_str", mock_get_secret_str)
monkeypatch.setattr(
"litellm.llms.meta.realtime.handler.MetaRealtime.async_realtime",
mock_async_realtime,
)
await realtime_main._arealtime.__wrapped__(
model="meta/muse-voice-transcribe-1.0",
websocket=MagicMock(),
litellm_logging_obj=FakeLogging(),
api_key=explicit_key,
query_params={"model": "meta/muse-voice-transcribe-1.0", "intent": "transcription"},
)
assert captured["model"] == "muse-voice-transcribe-1.0"
assert captured["api_key"] == expected
assert captured["query_params"] == {"model": "muse-voice-transcribe-1.0", "intent": "transcription"}
@pytest.mark.asyncio
async def test_arealtime_vertex_branch_resolves_credentials_under_a_bound(monkeypatch):
"""The wiring half of the regression: the vertex branch of _arealtime must