Merge pull request #39395 from seyeong-han/litellm_meta_muse_voice_realtime
Some checks failed
ai-gateway image / ai-gateway release image (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
Code Quality Checks / python-310-import-smoke (push) Waiting to run
CI Coverage / assert-ci-coverage (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Publish basedpyright base counts / publish (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / integrations (push) Waiting to run
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / misc (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

feat(realtime): add Meta Muse Voice transcription
This commit is contained in:
ryan-crabbe-berri 2026-09-12 15:58:24 -07:00 committed by GitHub
commit c134fb7a38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1726 additions and 53 deletions

View file

@ -445,6 +445,12 @@ class RealTimeStreaming:
)
sent = False
for msg in transformed:
if isinstance(msg, bytes):
await self.provider_config.pace_backend_send(msg)
await self.backend_ws.send(msg)
self._content_sent_after_setup = True
sent = True
continue
try:
msg_obj = _decode_json_object(msg)
except (json.JSONDecodeError, TypeError):
@ -1013,7 +1019,7 @@ class RealTimeStreaming:
cast(str, transcript),
item_id=cast(str | None, event.get("item_id")),
)
if not blocked:
if not blocked and not self._is_transcription_session:
await self._send_to_backend(json.dumps({"type": "response.create"}))
continue
## LOGGING

View file

@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any
import httpx
@ -54,9 +55,12 @@ class BaseRealtimeConfig(ABC):
message: str,
model: str,
session_configuration_request: str | None = None,
) -> list[str]:
) -> Sequence[str | bytes]:
pass
async def pace_backend_send(self, message: bytes) -> None:
return None
def is_setup_message(self, msg_obj: dict) -> bool:
return False
@ -79,7 +83,7 @@ class BaseRealtimeConfig(ABC):
model: str,
logging_session_id: str,
session_configuration_request: str | None = None,
) -> dict | OpenAIRealtimeStreamSessionEvents | None:
) -> Mapping[str, object] | OpenAIRealtimeStreamSessionEvents | None:
"""
Optional hook for providers that defer session setup until client `session.update`.

View file

@ -0,0 +1,719 @@
import asyncio
import base64
import binascii
import json
import math
import time
from collections.abc import Awaitable, Callable, Iterator, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal
from urllib.parse import urlparse, urlunparse
from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.meta import MuseAudioEncoding, MuseHandshake, MuseMode, MuseSampleRate
from litellm.types.llms.openai import (
OpenAIRealtimeErrorEvent,
OpenAIRealtimeEvents,
OpenAIRealtimeInputAudioBufferSpeechEvent,
OpenAIRealtimeInputAudioTranscriptionCompleted,
OpenAIRealtimeInputAudioTranscriptionDelta,
OpenAIRealtimeServerVadTurnDetection,
OpenAIRealtimeTranscriptionSession,
OpenAIRealtimeTranscriptionSessionCreated,
OpenAIRealtimeTranscriptionSettings,
)
from litellm.types.realtime import (
RealtimeInputAudioTranscriptionDurationUsage,
RealtimeInputAudioTranscriptionUsage,
RealtimeResponseTransformInput,
RealtimeResponseTypedDict,
)
MUSE_MODEL: Final = "muse-voice-transcribe-1.0"
DEFAULT_MUSE_REALTIME_URL: Final = "wss://api.meta.ai/v1/asr/realtime"
SUPPORTED_SAMPLE_RATES: Final = frozenset((16_000, 24_000))
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 = MappingProxyType({language.casefold(): language for language in SUPPORTED_LANGUAGES})
_LANGUAGE_CODES: Final = MappingProxyType(
{
"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",
}
)
_SUPPORTED_TRANSCRIPTION_KEYS: Final = frozenset(("model", "language"))
_MAX_AUDIO_BACKLOG_SECONDS: Final = 4
_PACKET_MS: Final = 80
_END_STREAM: Final = '{"type":"endStream"}'
_PROVIDER_ERROR_MESSAGE: Final = "Meta Muse realtime transcription failed"
_JSON_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_EMPTY_OBJECT: Final[Mapping[str, JsonValue]] = MappingProxyType({})
_SERVER_VAD: Final[OpenAIRealtimeServerVadTurnDetection] = {"type": "server_vad"}
class MuseProtocolError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class MuseSessionConfig:
model: str
mode: MuseMode
sample_rate: MuseSampleRate
language_bias: tuple[str, ...]
@property
def audio_encoding(self) -> MuseAudioEncoding:
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 * _PACKET_MS // 1000
@property
def max_encoded_append_bytes(self) -> int:
return 4 * ((self.bytes_per_second * _MAX_AUDIO_BACKLOG_SECONDS + 2) // 3)
def handshake(self, access_token: str) -> MuseHandshake:
base: Final[MuseHandshake] = {
"authorization": {"accessToken": access_token},
"audioEncoding": self.audio_encoding,
"model": self.model,
"mode": self.mode,
"partialMode": "CUMULATIVE",
"emitAudioProgress": True,
}
if not self.language_bias:
return base
biased: Final[MuseHandshake] = {**base, "languageBias": self.language_bias}
return biased
def openai_session(self, session_id: str) -> OpenAIRealtimeTranscriptionSession:
session: Final[OpenAIRealtimeTranscriptionSession] = {
"id": session_id,
"object": "realtime.transcription_session",
"type": "transcription",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": self.sample_rate},
"transcription": self._transcription_settings(),
"turn_detection": None if self.mode == "PUSH_TO_TALK" else _SERVER_VAD,
}
},
}
return session
def _transcription_settings(self) -> OpenAIRealtimeTranscriptionSettings:
base: Final[OpenAIRealtimeTranscriptionSettings] = {"model": self.model}
if not self.language_bias:
return base
localized: Final[OpenAIRealtimeTranscriptionSettings] = {**base, "language": self.language_bias[0]}
return localized
_DEFAULT_SESSION_CONFIG: Final = MuseSessionConfig(
model=MUSE_MODEL, mode="ENDPOINTING", sample_rate=24_000, language_bias=()
)
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 _EMPTY_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 _event_id() -> str:
return f"event_{uuid.uuid4().hex}"
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_access_token(api_key: str) -> str:
stripped: Final = api_key.strip()
if not stripped:
raise ValueError("Meta API key is required")
parts: Final = stripped.split(None, 1)
if parts[0].casefold() != "bearer":
return f"Bearer {stripped}"
if len(parts) != 2 or not parts[1].strip():
raise ValueError("Meta API key must include a token after Bearer")
return f"Bearer {parts[1].strip()}"
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 _parse_sample_rate(session: Mapping[str, JsonValue]) -> MuseSampleRate:
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 16_000 if rate == 16_000 else 24_000
def _parse_mode(session: Mapping[str, JsonValue], audio_input: Mapping[str, JsonValue]) -> MuseMode:
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")
if session.get("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",
)
unsupported: Final = tuple(sorted(key for key in transcription if key not in _SUPPORTED_TRANSCRIPTION_KEYS))
if unsupported:
verbose_logger.warning("Meta realtime: dropping unsupported transcription settings %s", unsupported)
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: Final = _string(transcription.get("language"), "language")
return MuseSessionConfig(
model=normalized_model,
mode=_parse_mode(session, audio_input),
sample_rate=_parse_sample_rate(session),
language_bias=() if language is None else (normalize_language(language),),
)
def session_created_event(config: MuseSessionConfig, session_id: str) -> OpenAIRealtimeTranscriptionSessionCreated:
event: Final[OpenAIRealtimeTranscriptionSessionCreated] = {
"type": "session.created",
"event_id": _event_id(),
"session": config.openai_session(session_id),
}
return event
def error_event(message: str) -> OpenAIRealtimeErrorEvent:
event: Final[OpenAIRealtimeErrorEvent] = {
"type": "error",
"error": {"type": "server_error", "message": message},
}
return event
def _speech_event(
event_type: Literal["input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped"], item_id: str
) -> OpenAIRealtimeInputAudioBufferSpeechEvent:
event: Final[OpenAIRealtimeInputAudioBufferSpeechEvent] = {
"type": event_type,
"event_id": _event_id(),
"item_id": item_id,
}
return event
def _delta_event(item_id: str, delta: str) -> OpenAIRealtimeInputAudioTranscriptionDelta:
event: Final[OpenAIRealtimeInputAudioTranscriptionDelta] = {
"type": "conversation.item.input_audio_transcription.delta",
"event_id": _event_id(),
"item_id": item_id,
"content_index": 0,
"delta": delta,
}
return event
def _completed_event(
item_id: str, transcript: str, usage: RealtimeInputAudioTranscriptionUsage | None
) -> OpenAIRealtimeInputAudioTranscriptionCompleted:
event: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {
"type": "conversation.item.input_audio_transcription.completed",
"event_id": _event_id(),
"item_id": item_id,
"content_index": 0,
"transcript": transcript,
}
if usage is None:
return event
billed: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {**event, "usage": usage}
return billed
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
def _new_suffix(previous: str, current: str) -> str:
return current[len(previous) :] if current.startswith(previous) else ""
@dataclass(slots=True)
class _TurnState:
item_id: str
started: bool = False
start_emitted: bool = False
latest_partial: str | None = None
emitted_partial: str = ""
final_text: str | None = None
completed_emitted: bool = False
stopped: bool = False
stopped_emitted: bool = False
def finish(self, transcript: str) -> None:
self.final_text = transcript
self.stopped = True
def drain(
self, take_usage: Callable[[], RealtimeInputAudioTranscriptionUsage | None]
) -> Iterator[OpenAIRealtimeEvents]:
has_content: Final = self.latest_partial is not None or self.final_text is not None
if (self.started or has_content) and not self.start_emitted:
self.start_emitted = True
yield _speech_event("input_audio_buffer.speech_started", self.item_id)
if self.latest_partial is not None and self.final_text is None:
delta: Final = _new_suffix(self.emitted_partial, self.latest_partial)
if delta:
self.emitted_partial = self.latest_partial
yield _delta_event(self.item_id, delta)
if self.stopped and not self.stopped_emitted:
self.stopped_emitted = True
yield _speech_event("input_audio_buffer.speech_stopped", self.item_id)
if self.final_text is not None and self.stopped_emitted and not self.completed_emitted:
self.completed_emitted = True
yield _completed_event(self.item_id, self.final_text, take_usage())
class MuseEventTransformer:
def __init__(self, *, turn_limit: int = 128) -> None:
self._turns: dict[str, _TurnState] = {} # mutable-ok: bounded, insertion-ordered per-turn emit state
self._turn_limit: Final = turn_limit
self._active_turn_id: str | None = None
self._mode: MuseMode = "ENDPOINTING"
self._last_audio_processed_ms: float = 0.0
self._unbilled_seconds: float = 0.0
def configure(self, config: MuseSessionConfig) -> None:
self._mode = config.mode
def transform(self, message: Mapping[str, JsonValue]) -> tuple[OpenAIRealtimeEvents, ...]:
event_type: Final = message.get("type")
if event_type == "error":
return (error_event(_PROVIDER_ERROR_MESSAGE),)
if event_type == "audioProgress":
self._update_audio_progress(message)
return ()
turn: Final = self._apply_turn_event(event_type, message)
if turn is None:
return ()
return tuple(turn.drain(self.take_unbilled_usage))
def take_unbilled_usage(self) -> RealtimeInputAudioTranscriptionUsage | None:
seconds: Final = self._unbilled_seconds
if seconds <= 0:
return None
self._unbilled_seconds = 0.0
usage: Final[RealtimeInputAudioTranscriptionDurationUsage] = {"type": "duration", "seconds": seconds}
return usage
def _apply_turn_event(self, event_type: JsonValue | None, message: Mapping[str, JsonValue]) -> _TurnState | None:
match event_type:
case "speechStart":
return self._speech_start(message)
case "transcript":
return self._transcript(message)
case "speechEnd":
return self._speech_end(message)
case "speechComplete":
return self._speech_complete(message)
case _:
return None
def _turn(self, turn_id: str) -> _TurnState:
existing: Final = self._turns.get(turn_id)
if existing is not None:
return existing
created: Final = _TurnState(item_id=turn_id)
self._turns[turn_id] = created
if len(self._turns) > self._turn_limit:
del self._turns[next(iter(self._turns))]
return created
def _speech_start(self, message: Mapping[str, JsonValue]) -> _TurnState:
turn: Final = self._turn(_required_turn_id(message, "speechStart"))
if turn.stopped:
return turn
turn.started = True
self._active_turn_id = turn.item_id
return turn
def _transcript(self, message: Mapping[str, JsonValue]) -> _TurnState | 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 None
turn: Final = self._turn(self._transcript_turn_id(message))
if message.get("final") is True:
self._finish(turn, transcript)
elif turn.final_text is None:
turn.latest_partial = transcript
return turn
def _speech_end(self, message: Mapping[str, JsonValue]) -> _TurnState:
turn: Final = self._turn(_required_turn_id(message, "speechEnd"))
turn.stopped = True
return turn
def _speech_complete(self, message: Mapping[str, JsonValue]) -> _TurnState:
transcript: Final = message.get("transcript")
if not isinstance(transcript, str):
raise MuseProtocolError("speechComplete event has invalid transcript")
turn: Final = self._turn(_required_turn_id(message, "speechComplete"))
self._finish(turn, transcript)
return turn
def _finish(self, turn: _TurnState, transcript: str) -> None:
turn.finish(transcript)
self._release_active(turn)
def _release_active(self, turn: _TurnState) -> None:
if self._active_turn_id == turn.item_id:
self._active_turn_id = None
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._unbilled_seconds += (float(processed_ms) - self._last_audio_processed_ms) / 1000
self._last_audio_processed_ms = float(processed_ms)
def _transcript_turn_id(self, message: Mapping[str, JsonValue]) -> str:
if message.get("turnId") is not None:
return _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
class MetaRealtimeConfig(BaseRealtimeConfig):
def __init__(
self,
*,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._monotonic: Final = monotonic
self._sleep: Final = sleep
self._transformer: Final = MuseEventTransformer()
self._access_token: str | None = None
self._config: MuseSessionConfig | None = None
self._pending_audio: bytes = b""
self._end_stream_sent: bool = False
self._pacing_origin: float | None = None
self._sent_duration: float = 0.0
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseRealtimeConfig contract
model: str,
api_key: str | None = None,
) -> dict[str, str]: # mutable-ok: BaseRealtimeConfig contract
token: Final = api_key or get_secret_str("META_API_KEY")
if token is None:
raise ValueError("api_key is required for Meta API calls")
self._access_token = normalize_access_token(token)
return headers
def get_complete_url(self, api_base: str | None, model: str, api_key: str | None = None) -> str:
if _normalize_model(model) != MUSE_MODEL:
raise ValueError(f"Unsupported Meta realtime model: {model}")
return build_muse_realtime_url(api_base)
def is_setup_message(self, msg_obj: Mapping[str, object]) -> bool:
return "authorization" in msg_obj
def transform_session_created_event(
self,
model: str,
logging_session_id: str,
session_configuration_request: str | None = None,
) -> OpenAIRealtimeTranscriptionSessionCreated:
return session_created_event(_DEFAULT_SESSION_CONFIG, logging_session_id)
def transform_realtime_request(
self,
message: str,
model: str,
session_configuration_request: str | None = None,
) -> tuple[str | bytes, ...]:
request: Final = _json_object(message)
event_type: Final = request.get("type")
if event_type in ("session.update", "transcription_session.update"):
return self._configure(message, model)
if event_type == "input_audio_buffer.append":
return self._append_audio(request)
if event_type == "input_audio_buffer.commit":
return self._flush_audio(end_stream=self._require_config().mode == "PUSH_TO_TALK")
if event_type == "input_audio_buffer.end":
return self._flush_audio(end_stream=True)
if event_type == "input_audio_buffer.clear":
self._pending_audio = b""
return ()
verbose_logger.debug("Meta realtime: dropping unsupported client event %s", event_type)
return ()
async def pace_backend_send(self, message: bytes) -> None:
now: Final = self._monotonic()
origin: Final = self._pacing_origin
effective_origin: Final = (
now - self._sent_duration if origin is None or now > origin + self._sent_duration else origin
)
delay: Final = effective_origin + self._sent_duration - now
if delay > 0:
await self._sleep(delay)
self._pacing_origin = effective_origin
self._sent_duration += len(message) / self._require_config().bytes_per_second
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return self._transformer.take_unbilled_usage()
def transform_realtime_response(
self,
message: str | bytes,
model: str,
logging_obj: LiteLLMLoggingObj,
realtime_response_transform_input: RealtimeResponseTransformInput,
) -> RealtimeResponseTypedDict:
payload: Final = message.decode("utf-8") if isinstance(message, bytes) else message
result: Final[RealtimeResponseTypedDict] = {
"response": list(self._backend_events(payload)), # mutable-ok: RealtimeResponseTypedDict.response is a list
"current_output_item_id": realtime_response_transform_input.get("current_output_item_id"),
"current_response_id": realtime_response_transform_input.get("current_response_id"),
"current_delta_chunks": realtime_response_transform_input.get("current_delta_chunks"),
"current_conversation_id": realtime_response_transform_input.get("current_conversation_id"),
"current_item_chunks": realtime_response_transform_input.get("current_item_chunks"),
"current_delta_type": realtime_response_transform_input.get("current_delta_type"),
"session_configuration_request": realtime_response_transform_input.get("session_configuration_request"),
}
return result
def _backend_events(self, payload: str) -> tuple[OpenAIRealtimeEvents, ...]:
frame: Final = _json_object(payload)
session_id: Final = frame.get("sessionId")
if session_id is None:
return self._transformer.transform(frame)
if not isinstance(session_id, str) or not session_id.strip():
raise MuseProtocolError("provider returned an invalid handshake response")
return (session_created_event(self._require_config(), session_id.strip()),)
def _configure(self, message: str, model: str) -> tuple[str, ...]:
if self._config is not None:
verbose_logger.debug("Meta realtime: ignoring session.update after the Muse handshake was sent")
return ()
access_token: Final = self._access_token
if access_token is None:
raise MuseProtocolError("Meta API key was not validated before the session was configured")
config: Final = parse_session_update(message, model)
self._config = config
self._transformer.configure(config)
return (json.dumps(config.handshake(access_token), separators=(",", ":")),)
def _append_audio(self, request: Mapping[str, JsonValue]) -> tuple[bytes, ...]:
config: Final = self._require_config()
encoded: Final = request.get("audio")
if not isinstance(encoded, str):
raise MuseProtocolError("Audio must be a base64 string")
if len(encoded) > config.max_encoded_append_bytes:
raise MuseProtocolError("Audio append exceeds the four-second backlog limit")
try:
audio: Final = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
raise MuseProtocolError("Audio must be valid base64") from None
if len(audio) % 2:
raise MuseProtocolError("PCM16 audio must contain complete samples")
buffered: Final = self._pending_audio + audio
packet_end: Final = len(buffered) - len(buffered) % config.packet_bytes
self._pending_audio = buffered[packet_end:]
return tuple(
buffered[start : start + config.packet_bytes] for start in range(0, packet_end, config.packet_bytes)
)
def _flush_audio(self, *, end_stream: bool) -> tuple[str | bytes, ...]:
remainder: Final = self._pending_audio
self._pending_audio = b""
frames: Final[tuple[bytes, ...]] = (remainder,) if remainder else ()
if not end_stream or self._end_stream_sent:
return frames
self._end_stream_sent = True
return (*frames, _END_STREAM)
def _require_config(self) -> MuseSessionConfig:
if self._config is None:
raise MuseProtocolError("session.update must configure the Muse session before audio is sent")
return self._config

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

@ -34719,6 +34719,22 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta/muse-voice-transcribe-1.0": {
"input_cost_per_second": 0.00005,
"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,
@ -59108,9 +59124,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/"
},
@ -59118,9 +59134,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

@ -0,0 +1,21 @@
from typing import Literal, TypeAlias
from typing_extensions import NotRequired, ReadOnly, TypedDict
MuseMode: TypeAlias = Literal["PUSH_TO_TALK", "ENDPOINTING"]
MuseAudioEncoding: TypeAlias = Literal["PCM_16KHZ", "PCM_24KHZ"]
MuseSampleRate: TypeAlias = Literal[16000, 24000]
class MuseAuthorization(TypedDict):
accessToken: ReadOnly[str]
class MuseHandshake(TypedDict):
authorization: ReadOnly[MuseAuthorization]
audioEncoding: ReadOnly[MuseAudioEncoding]
model: ReadOnly[str]
mode: ReadOnly[MuseMode]
partialMode: ReadOnly[Literal["CUMULATIVE"]]
emitAudioProgress: ReadOnly[bool]
languageBias: NotRequired[ReadOnly[tuple[str, ...]]]

View file

@ -2190,6 +2190,53 @@ class OpenAIRealtimeInputAudioBufferSpeechEvent(TypedDict):
item_id: ReadOnly[str]
class OpenAIRealtimeErrorDetail(TypedDict):
type: ReadOnly[str]
message: ReadOnly[str]
class OpenAIRealtimeErrorEvent(TypedDict):
type: ReadOnly[Literal["error"]]
error: ReadOnly[OpenAIRealtimeErrorDetail]
class OpenAIRealtimeTranscriptionAudioFormat(TypedDict):
type: ReadOnly[Literal["audio/pcm"]]
rate: ReadOnly[int]
class OpenAIRealtimeTranscriptionSettings(TypedDict):
model: ReadOnly[str]
language: NotRequired[ReadOnly[str]]
class OpenAIRealtimeServerVadTurnDetection(TypedDict):
type: ReadOnly[Literal["server_vad"]]
class OpenAIRealtimeTranscriptionAudioInput(TypedDict):
format: ReadOnly[OpenAIRealtimeTranscriptionAudioFormat]
transcription: ReadOnly[OpenAIRealtimeTranscriptionSettings]
turn_detection: ReadOnly[OpenAIRealtimeServerVadTurnDetection | None]
class OpenAIRealtimeTranscriptionAudio(TypedDict):
input: ReadOnly[OpenAIRealtimeTranscriptionAudioInput]
class OpenAIRealtimeTranscriptionSession(TypedDict):
id: ReadOnly[str]
object: ReadOnly[Literal["realtime.transcription_session"]]
type: ReadOnly[Literal["transcription"]]
audio: ReadOnly[OpenAIRealtimeTranscriptionAudio]
class OpenAIRealtimeTranscriptionSessionCreated(TypedDict):
type: ReadOnly[Literal["session.created"]]
event_id: ReadOnly[str]
session: ReadOnly[OpenAIRealtimeTranscriptionSession]
class OpenAIRealtimeInputAudioTranscriptionDelta(TypedDict):
type: ReadOnly[Literal["conversation.item.input_audio_transcription.delta"]]
event_id: ReadOnly[str]
@ -2204,6 +2251,7 @@ class OpenAIRealtimeInputAudioTranscriptionCompleted(TypedDict):
item_id: ReadOnly[str]
content_index: ReadOnly[int]
transcript: ReadOnly[str]
usage: NotRequired[ReadOnly[Mapping[str, object]]]
class OpenAIRealtimeUsageTokenDetails(TypedDict):
@ -2260,6 +2308,8 @@ OpenAIRealtimeEvents = (
| OpenAIRealtimeInputAudioBufferSpeechEvent
| OpenAIRealtimeInputAudioTranscriptionDelta
| OpenAIRealtimeInputAudioTranscriptionCompleted
| OpenAIRealtimeTranscriptionSessionCreated
| OpenAIRealtimeErrorEvent
)
OpenAIRealtimeStreamList = list[OpenAIRealtimeEvents]

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

@ -9285,6 +9285,10 @@ class ProviderConfigManager:
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
return GeminiRealtimeConfig()
if LlmProviders.META == provider:
from litellm.llms.meta.realtime.transformation import MetaRealtimeConfig
return MetaRealtimeConfig()
return None
@staticmethod

View file

@ -34719,6 +34719,22 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta/muse-voice-transcribe-1.0": {
"input_cost_per_second": 0.00005,
"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,
@ -59108,9 +59124,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/"
},
@ -59118,9 +59134,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)
@ -3136,7 +3124,6 @@ async def test_session_close_flush_noop_without_unbilled_usage():
)
_UPSTREAM_REFUSAL: Final = "Publisher model `publishers/google/models/gemini-live-2.5-flash` was not found"
@ -3204,9 +3191,7 @@ def _backend_ws_closing_with(*frames: bytes | Exception) -> MagicMock:
def _relay_session(client_ws: MagicMock, backend_ws: MagicMock) -> _RelaySession:
logging: Final = _RecordingLogging()
worker: Final = _InlineLoggingWorker()
streaming: Final = RealTimeStreaming(
client_ws, backend_ws, logging, model="gpt-realtime", logging_worker=worker
)
streaming: Final = RealTimeStreaming(client_ws, backend_ws, logging, model="gpt-realtime", logging_worker=worker)
return _RelaySession(streaming=streaming, logging=logging, worker=worker)
@ -3412,3 +3397,136 @@ 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_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()
@pytest.mark.asyncio
async def test_transcription_session_still_runs_transcription_guardrail(monkeypatch: pytest.MonkeyPatch):
class BlockingGuardrail(CustomGuardrail):
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
raise ValueError("blocked transcript")
guardrail: Final = BlockingGuardrail(
guardrail_name="transcription-blocker",
event_hook=GuardrailEventHooks.realtime_input_transcription,
default_on=True,
)
monkeypatch.setattr(litellm, "callbacks", [guardrail])
completed_event: Final = {
"type": "conversation.item.input_audio_transcription.completed",
"event_id": "event_1",
"item_id": "turn_1",
"content_index": 0,
"transcript": "blocked 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 = ()
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("{}")
sent_to_client: Final = [json.loads(call.args[0]) for call in client_ws.send_text.await_args_list]
assert completed_event in sent_to_client
error_events: Final = [event for event in sent_to_client if event.get("type") == "error"]
assert len(error_events) == 1
assert error_events[0]["error"]["type"] == "guardrail_violation"
backend_ws.send.assert_not_awaited()
assert streaming._violation_count == 1
@pytest.mark.asyncio
async def test_provider_bytes_are_sent_raw_after_pacing():
from typing import Final
backend_ws: Final = MagicMock()
backend_ws.send = AsyncMock()
provider_config: Final = MagicMock()
provider_config.requires_session_configuration.return_value = True
provider_config.transform_realtime_request.return_value = (b"\x00\x01", '{"type":"endStream"}')
provider_config.pace_backend_send = AsyncMock()
provider_config.is_setup_message.return_value = False
streaming: Final = RealTimeStreaming(
MagicMock(),
backend_ws,
MagicMock(),
provider_config=provider_config,
model="muse-voice-transcribe-1.0",
)
assert await streaming._send_to_backend(json.dumps({"type": "input_audio_buffer.commit"})) is True
assert [call.args[0] for call in backend_ws.send.await_args_list] == [b"\x00\x01", '{"type":"endStream"}']
provider_config.pace_backend_send.assert_awaited_once_with(b"\x00\x01")

View file

@ -0,0 +1,683 @@
import base64
import itertools
import json
from typing import Final
from unittest.mock import MagicMock
import pytest
from litellm.llms.meta.realtime.transformation import (
DEFAULT_MUSE_REALTIME_URL,
MUSE_MODEL,
MetaRealtimeConfig,
MuseEventTransformer,
MuseProtocolError,
MuseSessionConfig,
build_muse_realtime_url,
normalize_access_token,
normalize_language,
parse_session_update,
session_created_event,
)
from litellm.types.llms.meta import MuseMode
from litellm.types.realtime import RealtimeResponseTransformInput
EMPTY_TRANSFORM_INPUT: Final[RealtimeResponseTransformInput] = {
"session_configuration_request": None,
"current_output_item_id": None,
"current_response_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_conversation_id": None,
"current_delta_type": None,
}
def _event(event_type: str, **fields: object) -> str:
return json.dumps({"type": event_type, **fields})
def _ga_session_update(rate: int = 24_000, turn_detection: object = "server_vad") -> str:
return _event(
"session.update",
session={
"type": "transcription",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": rate},
"turn_detection": None if turn_detection is None else {"type": turn_detection},
"transcription": {"model": f"meta/{MUSE_MODEL}"},
}
},
},
)
def _configured(rate: int = 24_000, turn_detection: object = "server_vad", **kwargs: object) -> MetaRealtimeConfig:
config = MetaRealtimeConfig(**kwargs)
config.validate_environment({}, MUSE_MODEL, api_key="secret-token")
config.transform_realtime_request(_ga_session_update(rate, turn_detection), MUSE_MODEL)
return config
def _backend_events(config: MetaRealtimeConfig, payload: str) -> list[dict[str, object]]:
response = config.transform_realtime_response(payload, MUSE_MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT)["response"]
assert isinstance(response, list)
return response
def test_beta_session_translates_language_and_drops_non_openai_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",
"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",)
assert config.handshake("Bearer token") == {
"mode": "ENDPOINTING",
"authorization": {"accessToken": "Bearer token"},
"audioEncoding": "PCM_24KHZ",
"model": MUSE_MODEL,
"partialMode": "CUMULATIVE",
"emitAudioProgress": True,
"languageBias": ("English",),
}
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"
assert "languageBias" not in MuseSessionConfig(MUSE_MODEL, "ENDPOINTING", 24_000, ()).handshake("Bearer token")
@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": {"language": "xx"}}, "unsupported Muse Voice language"),
({"turn_detection": {"type": "semantic_vad"}}, "server_vad turn detection or null"),
({"type": "realtime", "audio": {"input": {"turn_detection": {"type": "semantic_vad"}}}}, "server_vad"),
],
)
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_created_event_exposes_openai_transcription_shape():
config = parse_session_update(
_event(
"session.update",
session={
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 24000},
"transcription": {"model": MUSE_MODEL, "language": "ja"},
}
},
},
),
MUSE_MODEL,
)
created = session_created_event(config, "provider-session")
assert created["type"] == "session.created"
assert created["session"]["id"] == "provider-session"
assert created["session"]["type"] == "transcription"
assert created["session"]["audio"]["input"]["turn_detection"] == {"type": "server_vad"}
assert created["session"]["audio"]["input"]["transcription"] == {"model": MUSE_MODEL, "language": "Japanese"}
def test_turnless_empty_silence_transcript_is_ignored():
transformer = MuseEventTransformer()
assert transformer.transform(json.loads(_event("transcript", transcript="", final=True))) == ()
def test_transcript_without_speech_start_synthesizes_start_before_delta():
transformer = MuseEventTransformer()
events = transformer.transform(json.loads(_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()
def send(payload: str) -> tuple[dict[str, object], ...]:
return transformer.transform(json.loads(payload))
started = send(_event("speechStart", turnId="turn-1"))
first = send(_event("transcript", turnId="turn-1", transcript="hello", final=False))
extension = send(_event("transcript", turnId="turn-1", transcript="hello world", final=False))
rewrite = send(_event("transcript", turnId="turn-1", transcript="hullo world", final=False))
completed = send(_event("speechComplete", turnId="turn-1", transcript="hullo world"))
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"
assert send(_event("speechEnd", turnId="turn-1")) == ()
def test_speech_end_then_speech_complete_emits_stopped_then_completed():
transformer = MuseEventTransformer()
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
stopped = transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
completed = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="done")))
assert [event["type"] for event in stopped] == ["input_audio_buffer.speech_stopped"]
assert [event["type"] for event in completed] == ["conversation.item.input_audio_transcription.completed"]
assert completed[0]["transcript"] == "done"
def test_turnless_partial_between_speech_end_and_speech_complete_stays_on_that_turn():
transformer = MuseEventTransformer()
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
transformer.transform(json.loads(_event("transcript", transcript="what is", final=False)))
transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
post_processed = transformer.transform(
json.loads(_event("transcript", transcript="what is the weather", final=False))
)
completed = transformer.transform(
json.loads(_event("speechComplete", turnId="turn-1", transcript="What is the weather?"))
)
assert _typed(post_processed) == [("conversation.item.input_audio_transcription.delta", "turn-1")]
assert post_processed[0]["delta"] == " the weather"
assert _typed(completed) == [("conversation.item.input_audio_transcription.completed", "turn-1")]
assert completed[0]["transcript"] == "What is the weather?"
def _typed(events: tuple[dict[str, object], ...]) -> list[tuple[object, object]]:
return [(event["type"], event["item_id"]) for event in events]
def test_overlapping_turns_emit_independently_and_correlate_by_item_id():
transformer = MuseEventTransformer()
def send(payload: str) -> list[tuple[object, object]]:
return _typed(transformer.transform(json.loads(payload)))
assert send(_event("speechStart", turnId="turn-a")) == [("input_audio_buffer.speech_started", "turn-a")]
assert send(_event("speechStart", turnId="turn-b")) == [("input_audio_buffer.speech_started", "turn-b")]
assert send(_event("transcript", turnId="turn-b", transcript="second", final=False)) == [
("conversation.item.input_audio_transcription.delta", "turn-b")
]
assert send(_event("speechComplete", turnId="turn-a", transcript="first")) == [
("input_audio_buffer.speech_stopped", "turn-a"),
("conversation.item.input_audio_transcription.completed", "turn-a"),
]
assert send(_event("speechEnd", turnId="turn-a")) == []
assert send(_event("speechEnd", turnId="turn-b")) == [("input_audio_buffer.speech_stopped", "turn-b")]
assert send(_event("speechComplete", turnId="turn-b", transcript="second final")) == [
("conversation.item.input_audio_transcription.completed", "turn-b")
]
def test_empty_vad_turn_is_closed_and_does_not_block_the_next_turn():
transformer = MuseEventTransformer()
def send(payload: str) -> list[tuple[object, object]]:
return _typed(transformer.transform(json.loads(payload)))
assert send(_event("speechStart", turnId="noise")) == [("input_audio_buffer.speech_started", "noise")]
assert send(_event("speechEnd", turnId="noise")) == [("input_audio_buffer.speech_stopped", "noise")]
assert send(_event("speechStart", turnId="speech")) == [("input_audio_buffer.speech_started", "speech")]
assert send(_event("transcript", turnId="speech", transcript="hello", final=False)) == [
("conversation.item.input_audio_transcription.delta", "speech")
]
assert send(_event("speechEnd", turnId="speech")) == [("input_audio_buffer.speech_stopped", "speech")]
assert send(_event("speechComplete", turnId="speech", transcript="hello world")) == [
("conversation.item.input_audio_transcription.completed", "speech")
]
@pytest.mark.parametrize("transcript", ["", "late words"])
def test_late_speech_complete_after_an_empty_speech_end_completes_that_item(transcript: str):
transformer = MuseEventTransformer()
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
transformer.transform(json.loads(_event("speechStart", turnId="turn-2")))
(completed,) = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript=transcript)))
assert completed["type"] == "conversation.item.input_audio_transcription.completed"
assert completed["item_id"] == "turn-1"
assert completed["transcript"] == transcript
def test_push_to_talk_speech_complete_closes_the_turn_without_speech_end():
transformer = MuseEventTransformer()
transformer.configure(MuseSessionConfig(MUSE_MODEL, "PUSH_TO_TALK", 24_000, ()))
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="hel", final=False)))
events = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="hello")))
assert [event["type"] for event in events] == [
"input_audio_buffer.speech_stopped",
"conversation.item.input_audio_transcription.completed",
]
assert events[1]["transcript"] == "hello"
_TERMINAL_SIGNALS: Final = {
"speechEnd": _event("speechEnd", turnId="turn-1"),
"speechComplete": _event("speechComplete", turnId="turn-1", transcript="final words"),
"final": _event("transcript", turnId="turn-1", transcript="final words", final=True),
}
_TERMINAL_ORDERINGS: Final = tuple(
ordering for size in (1, 2, 3) for ordering in itertools.permutations(_TERMINAL_SIGNALS, size)
)
@pytest.mark.parametrize("mode", ["ENDPOINTING", "PUSH_TO_TALK"])
@pytest.mark.parametrize("ordering", _TERMINAL_ORDERINGS, ids="-".join)
def test_every_terminal_signal_order_closes_the_turn_exactly_once(mode: MuseMode, ordering: tuple[str, ...]):
transformer = MuseEventTransformer()
transformer.configure(MuseSessionConfig(MUSE_MODEL, mode, 24_000, ()))
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="fin", final=False)))
emitted = [
event["type"] for signal in ordering for event in transformer.transform(json.loads(_TERMINAL_SIGNALS[signal]))
]
replayed = [
event["type"] for signal in ordering for event in transformer.transform(json.loads(_TERMINAL_SIGNALS[signal]))
]
has_text = bool(set(ordering) & {"speechComplete", "final"})
assert emitted == [
"input_audio_buffer.speech_stopped",
*(["conversation.item.input_audio_transcription.completed"] if has_text else []),
]
assert replayed == []
def test_push_to_talk_final_transcript_completes_without_speech_end():
transformer = MuseEventTransformer()
transformer.configure(MuseSessionConfig(MUSE_MODEL, "PUSH_TO_TALK", 24_000, ()))
events = transformer.transform(json.loads(_event("transcript", transcript="hello there", final=True)))
assert [event["type"] for event in events] == [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"conversation.item.input_audio_transcription.completed",
]
assert events[2]["transcript"] == "hello there"
assert str(events[0]["item_id"]).startswith("item_")
def test_positive_audio_progress_deltas_attach_to_next_completion_and_speaker_is_ignored():
transformer = MuseEventTransformer()
def send(payload: str) -> tuple[dict[str, object], ...]:
return transformer.transform(json.loads(payload))
send(_event("audioProgress", audioProcessedMs=1000))
send(_event("audioProgress", audioProcessedMs=750))
send(_event("audioProgress", audioProcessedMs=1600))
assert send(_event("speaker", turnId=42, label=" Speaker 2 ")) == ()
completed = send(_event("speechComplete", turnId=42, transcript="hello"))
assert "speaker" not in completed[-1]
assert completed[-1]["usage"] == {"type": "duration", "seconds": 1.6}
assert transformer.take_unbilled_usage() is None
assert send(_event("speechEnd", turnId=42)) == ()
def test_trailing_audio_progress_is_returned_once():
transformer = MuseEventTransformer()
transformer.transform(json.loads(_event("audioProgress", audioProcessedMs=250)))
assert transformer.take_unbilled_usage() == {"type": "duration", "seconds": 0.25}
assert transformer.take_unbilled_usage() is None
def test_finished_turn_ignores_late_duplicates():
transformer = MuseEventTransformer()
released = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="done")))
assert [event["type"] for event in released] == [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"conversation.item.input_audio_transcription.completed",
]
assert transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="duplicate"))) == ()
assert transformer.transform(json.loads(_event("speechEnd", turnId="turn-1"))) == ()
assert transformer.transform(json.loads(_event("speechStart", turnId="turn-1"))) == ()
assert (
transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="late", final=False))) == ()
)
def test_late_duplicate_speech_start_does_not_capture_the_next_turnless_transcript():
transformer = MuseEventTransformer()
transformer.configure(MuseSessionConfig(MUSE_MODEL, "PUSH_TO_TALK", 24_000, ()))
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="first")))
assert transformer.transform(json.loads(_event("speechStart", turnId="turn-1"))) == ()
events = transformer.transform(json.loads(_event("transcript", transcript="second", final=True)))
assert [event["type"] for event in events] == [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"conversation.item.input_audio_transcription.completed",
]
assert events[2]["transcript"] == "second"
assert events[2]["item_id"] != "turn-1"
def test_turn_memory_is_bounded_by_turn_limit():
transformer = MuseEventTransformer(turn_limit=2)
transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="one")))
transformer.transform(json.loads(_event("speechComplete", turnId="turn-2", transcript="two")))
assert transformer.transform(json.loads(_event("speechEnd", turnId="turn-1"))) == ()
transformer.transform(json.loads(_event("speechComplete", turnId="turn-3", transcript="three")))
forgotten = transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
assert [event["type"] for event in forgotten] == ["input_audio_buffer.speech_stopped"]
def test_provider_error_is_sanitized_and_encodable():
token = "private-token"
provider_body = f"authorization failed for Bearer {token}"
transformed = MuseEventTransformer().transform(
json.loads(_event("error", code="AUTH", message=provider_body, request={"accessToken": token}))
)
encoded = json.dumps(transformed[0])
assert json.loads(encoded)["error"] == {
"type": "server_error",
"message": "Meta Muse realtime transcription failed",
}
assert token not in encoded
assert provider_body not in encoded
@pytest.mark.parametrize(
("raw", "expected"),
[("token", "Bearer token"), (" Bearer token ", "Bearer token"), ("bearer token", "Bearer token")],
)
def test_access_token_normalization_adds_single_bearer_prefix(raw: str, expected: str):
assert normalize_access_token(raw) == expected
@pytest.mark.parametrize("raw", ["", " ", "Bearer", " bearer "])
def test_access_token_normalization_rejects_empty_tokens(raw: str):
with pytest.raises(ValueError, match=r"token|key is required"):
normalize_access_token(raw)
@pytest.mark.parametrize(
("api_base", "expected"),
[
(None, DEFAULT_MUSE_REALTIME_URL),
("https://example.test/custom/path?ignored=yes", "wss://example.test/v1/asr/realtime"),
("wss://example.test:8443/other", "wss://example.test:8443/v1/asr/realtime"),
],
)
def test_realtime_url_pins_muse_path(api_base: str | None, expected: str):
assert build_muse_realtime_url(api_base) == expected
assert MetaRealtimeConfig().get_complete_url(api_base, f"meta/{MUSE_MODEL}") == expected
@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_realtime_url_rejects_insecure_or_ambiguous_bases(api_base: str):
with pytest.raises(ValueError, match="absolute wss:// or https://"):
build_muse_realtime_url(api_base)
def test_unsupported_model_is_rejected_before_connecting():
with pytest.raises(ValueError, match="Unsupported Meta realtime model: meta/other-model"):
MetaRealtimeConfig().get_complete_url(None, "meta/other-model")
def test_missing_api_key_is_rejected(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("META_API_KEY", raising=False)
with pytest.raises(ValueError, match="api_key is required for Meta API calls"):
MetaRealtimeConfig().validate_environment({}, MUSE_MODEL)
def test_bearer_token_travels_only_in_the_json_handshake():
config = MetaRealtimeConfig()
headers = {"x-existing": "kept"}
assert config.validate_environment(headers, MUSE_MODEL, api_key="secret-token") == {"x-existing": "kept"}
(handshake,) = config.transform_realtime_request(_ga_session_update(), MUSE_MODEL)
assert isinstance(handshake, str)
assert json.loads(handshake)["authorization"] == {"accessToken": "Bearer secret-token"}
assert config.is_setup_message(json.loads(handshake)) is True
assert config.is_setup_message({"type": "input_audio_buffer.append"}) is False
assert config.transform_realtime_request(_ga_session_update(), MUSE_MODEL) == ()
def test_synthetic_session_created_uses_default_transcription_shape():
created = MetaRealtimeConfig().transform_session_created_event(f"meta/{MUSE_MODEL}", "trace-1")
assert created["type"] == "session.created"
assert created["session"]["id"] == "trace-1"
assert created["session"]["audio"]["input"]["format"] == {"type": "audio/pcm", "rate": 24000}
assert created["session"]["audio"]["input"]["transcription"] == {"model": MUSE_MODEL}
def test_audio_before_session_update_is_rejected():
config = MetaRealtimeConfig()
config.validate_environment({}, MUSE_MODEL, api_key="secret-token")
with pytest.raises(MuseProtocolError, match=r"session\.update must configure"):
config.transform_realtime_request(_event("input_audio_buffer.append", audio="AAAA"), MUSE_MODEL)
@pytest.mark.parametrize(("rate", "packet_bytes"), [(16_000, 2_560), (24_000, 3_840)])
def test_pcm_is_packetized_into_raw_binary_frames(rate: int, packet_bytes: int):
config = _configured(rate=rate)
pcm = b"\xff\xfe\x00\x80" * (packet_bytes // 2) + b"\x01\x02\x03\x04"
frames = config.transform_realtime_request(
_event("input_audio_buffer.append", audio=base64.b64encode(pcm).decode()), MUSE_MODEL
)
remainder = config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL)
assert frames == (pcm[:packet_bytes], pcm[packet_bytes : packet_bytes * 2])
assert remainder == (pcm[packet_bytes * 2 :],)
@pytest.mark.parametrize(
("audio", "message"),
[
("not base64!", "valid base64"),
(base64.b64encode(b"\x00").decode(), "complete samples"),
(12, "base64 string"),
("A" * (4 * ((24_000 * 2 * 4 + 2) // 3) + 4), "four-second backlog"),
],
)
def test_invalid_audio_appends_are_rejected(audio: object, message: str):
config = _configured()
with pytest.raises(MuseProtocolError, match=message):
config.transform_realtime_request(_event("input_audio_buffer.append", audio=audio), MUSE_MODEL)
@pytest.mark.asyncio
async def test_backend_sends_are_paced_to_real_time():
sleeps: list[float] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
config = _configured(monotonic=lambda: 10.0, sleep=record_sleep)
packet = b"\x01\x02" * 1_920
await config.pace_backend_send(packet)
await config.pace_backend_send(packet)
await config.pace_backend_send(packet)
assert sleeps == pytest.approx([0.08, 0.16])
def test_endpointing_commit_flushes_without_end_stream_but_end_sends_it_once():
config = _configured(turn_detection="server_vad")
assert config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL) == ()
assert config.transform_realtime_request(_event("input_audio_buffer.end"), MUSE_MODEL) == ('{"type":"endStream"}',)
assert config.transform_realtime_request(_event("input_audio_buffer.end"), MUSE_MODEL) == ()
def test_push_to_talk_commit_ends_the_stream_once():
config = _configured(turn_detection=None)
config.transform_realtime_request(
_event("input_audio_buffer.append", audio=base64.b64encode(b"\x01\x02").decode()), MUSE_MODEL
)
assert config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL) == (
b"\x01\x02",
'{"type":"endStream"}',
)
assert config.transform_realtime_request(_event("input_audio_buffer.end"), MUSE_MODEL) == ()
def test_clear_drops_buffered_remainder_and_unknown_events_are_ignored():
config = _configured()
config.transform_realtime_request(
_event("input_audio_buffer.append", audio=base64.b64encode(b"\x01\x02").decode()), MUSE_MODEL
)
assert config.transform_realtime_request(_event("input_audio_buffer.clear"), MUSE_MODEL) == ()
assert config.transform_realtime_request(_event("response.create"), MUSE_MODEL) == ()
assert config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL) == ()
def test_provider_ack_becomes_session_created_with_provider_id():
config = _configured(rate=16_000, turn_detection=None)
(created,) = _backend_events(config, json.dumps({"sessionId": " provider-session "}))
assert created["type"] == "session.created"
assert created["session"]["id"] == "provider-session"
assert created["session"]["audio"]["input"]["format"]["rate"] == 16000
assert created["session"]["audio"]["input"]["turn_detection"] is None
def test_provider_turn_events_and_close_usage_flow_through_config():
config = _configured()
assert _backend_events(config, json.dumps({"type": "audioProgress", "audioProcessedMs": 1349})) == []
assert _backend_events(config, _event("speechStart", turnId="t1"))[0]["type"] == "input_audio_buffer.speech_started"
assert _backend_events(config, _event("speechEnd", turnId="t1"))[0]["type"] == "input_audio_buffer.speech_stopped"
completed = _backend_events(config, _event("speechComplete", turnId="t1", transcript="what is the weather"))
assert [event["type"] for event in completed] == ["conversation.item.input_audio_transcription.completed"]
assert completed[0]["usage"] == {"type": "duration", "seconds": 1.349}
assert config.unbilled_usage_on_session_close(MUSE_MODEL) is None
assert _backend_events(config, json.dumps({"type": "audioProgress", "audioProcessedMs": 2349})) == []
assert config.unbilled_usage_on_session_close(MUSE_MODEL) == {"type": "duration", "seconds": 1.0}
def test_provider_error_frame_becomes_openai_error_without_leaking_token():
config = _configured()
(error,) = _backend_events(config, _event("error", message="bad token secret-token"))
assert error == {
"type": "error",
"error": {"type": "server_error", "message": "Meta Muse realtime transcription failed"},
}
assert "secret-token" not in json.dumps(error)
def test_invalid_provider_ack_is_rejected():
config = _configured()
with pytest.raises(MuseProtocolError, match="invalid handshake response"):
_backend_events(config, json.dumps({"sessionId": ""}))

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,33 @@ async def test_vertex_credential_resolution_bounds_a_thread_offloaded_refresh():
assert time.monotonic() - start < 5
@pytest.mark.asyncio
async def test_meta_realtime_dispatches_to_base_handler_with_meta_config(monkeypatch: pytest.MonkeyPatch):
from litellm.llms.meta.realtime.transformation import MetaRealtimeConfig
captured: dict[str, object] = {}
def mock_get_llm_provider(model, api_base, api_key):
return model.removeprefix("meta/"), "meta", None, api_base
async def mock_async_realtime(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(realtime_main, "get_llm_provider", mock_get_llm_provider)
monkeypatch.setattr(realtime_main.base_llm_http_handler, "async_realtime", mock_async_realtime)
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", "intent": "transcription"},
)
assert isinstance(captured["provider_config"], MetaRealtimeConfig)
assert captured["model"] == "muse-voice-transcribe-1.0"
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