diff --git a/README.md b/README.md index e927c80b8b49..a7005ac0c93d 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,7 @@ For MCP OAuth, an upstream may advertise dynamic client registration but refuse | [Replicate (`replicate`)](https://docs.litellm.ai/docs/providers/replicate) | ✅ | ✅ | ✅ | | | | | | | | | [Sagemaker Chat (`sagemaker_chat`)](https://docs.litellm.ai/docs/providers/aws_sagemaker) | ✅ | ✅ | ✅ | | | | | | | | | [Sambanova (`sambanova`)](https://docs.litellm.ai/docs/providers/sambanova) | ✅ | ✅ | ✅ | | | | | | | | +| [Sarvam (`sarvam`)](https://docs.litellm.ai/docs/providers/sarvam) | ✅ | ✅ | ✅ | | | | | | | | | [Snowflake (`snowflake`)](https://docs.litellm.ai/docs/providers/snowflake) | ✅ | ✅ | ✅ | | | | | | | | | [Text Completion Codestral (`text-completion-codestral`)](https://docs.litellm.ai/docs/providers/codestral) | ✅ | ✅ | ✅ | | | | | | | | | [Text Completion OpenAI (`text-completion-openai`)](https://docs.litellm.ai/docs/providers/text_completion_openai) | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | | diff --git a/litellm/llms/base_llm/realtime/transcription_protocol.py b/litellm/llms/base_llm/realtime/transcription_protocol.py index c3264911d461..e67fd2c2dd01 100644 --- a/litellm/llms/base_llm/realtime/transcription_protocol.py +++ b/litellm/llms/base_llm/realtime/transcription_protocol.py @@ -16,6 +16,7 @@ OpenAIRealtimeServerVadTurnDetection, OpenAIRealtimeTranscriptionSession, OpenAIRealtimeTranscriptionSessionCreated, + OpenAIRealtimeTranscriptionSessionUpdated, OpenAIRealtimeTranscriptionSettings, ) from litellm.types.realtime import RealtimeInputAudioTranscriptionDurationUsage, RealtimeInputAudioTranscriptionUsage @@ -224,6 +225,17 @@ def transcription_session_created_event( return event +def transcription_session_updated_event( + session: OpenAIRealtimeTranscriptionSession, +) -> OpenAIRealtimeTranscriptionSessionUpdated: + event: Final[OpenAIRealtimeTranscriptionSessionUpdated] = { + "type": "session.updated", + "event_id": new_event_id(), + "session": session, + } + return event + + def error_event(message: str) -> OpenAIRealtimeErrorEvent: event: Final[OpenAIRealtimeErrorEvent] = { "type": "error", diff --git a/litellm/llms/sarvam/realtime/transformation.py b/litellm/llms/sarvam/realtime/transformation.py new file mode 100644 index 000000000000..39b458d7a5f9 --- /dev/null +++ b/litellm/llms/sarvam/realtime/transformation.py @@ -0,0 +1,551 @@ +import base64 +import json +import math +from collections.abc import Mapping +from dataclasses import dataclass +from ipaddress import ip_address +from types import MappingProxyType +from typing import Final +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +from pydantic import JsonValue + +import litellm +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.transcription_protocol import ( + RealtimeTranscriptionProtocolError, + TranscriptionSessionUpdate, + completed_event, + decode_pcm16_append, + delta_event, + duration_usage, + error_event, + json_object, + json_string, + parse_transcription_session_update, + speech_event, + transcription_session, + transcription_session_created_event, + transcription_session_updated_event, +) +from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import ( + OpenAIRealtimeEvents, + OpenAIRealtimeTranscriptionSession, +) +from litellm.types.llms.sarvam import ( + SarvamAudioInput, + SarvamConfigUpdate, + SarvamControlEvent, + SarvamControlEventType, + SarvamEndpointing, + SarvamSampleRate, +) +from litellm.types.realtime import ( + RealtimeInputAudioTranscriptionUsage, + RealtimeResponseTransformInput, + RealtimeResponseTypedDict, +) +from litellm.types.utils import LlmProviders + +DEFAULT_SARVAM_REALTIME_API_BASE: Final = "wss://api.sarvam.ai" +SARVAM_REALTIME_PATH: Final = "/speech-to-text-realtime/ws" +SARVAM_REALTIME_ENCODING: Final = "linear16" +DEFAULT_SARVAM_REALTIME_MODEL: Final = "saaras:v3-realtime" +REALTIME_ENDPOINT: Final = "/v1/realtime" +SUPPORTED_SAMPLE_RATES: Final = frozenset({8_000, 16_000}) +AUTO_LANGUAGE: Final = "auto" +SUPPORTED_LANGUAGE_CODES: Final = ( + "as-IN", + "bn-IN", + "brx-IN", + "doi-IN", + "en-IN", + "gu-IN", + "hi-IN", + "kn-IN", + "kok-IN", + "ks-IN", + "mai-IN", + "ml-IN", + "mni-IN", + "mr-IN", + "ne-IN", + "or-IN", + "pa-IN", + "sa-IN", + "sat-IN", + "sd-IN", + "ta-IN", + "te-IN", + "ur-IN", +) +_LANGUAGE_BY_CODE: Final = MappingProxyType({code.casefold(): code for code in SUPPORTED_LANGUAGE_CODES}) +_LANGUAGE_BY_PRIMARY: Final = MappingProxyType({code.split("-")[0]: code for code in SUPPORTED_LANGUAGE_CODES}) +_WEBSOCKET_SCHEMES: Final = MappingProxyType({"https": "wss", "wss": "wss", "http": "ws", "ws": "ws"}) +_UTTERANCE_LIMIT: Final = 128 + + +class SarvamProtocolError(RealtimeTranscriptionProtocolError): + pass + + +def is_realtime_transcription_model(model: str) -> bool: + """Support follows the model registry rather than a list in this file, so a new Sarvam realtime model + only needs a cost map entry. Only an exact hit counts: the registry resolves unknown names to a family.""" + registry_key: Final = f"{LlmProviders.SARVAM.value}/{model}" + try: + model_info: Final = litellm.get_model_info(model=registry_key, custom_llm_provider=LlmProviders.SARVAM.value) + except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models + return False + return ( + model_info["key"] == registry_key + and model_info.get("mode") == "audio_transcription" + and REALTIME_ENDPOINT in (model_info.get("supported_endpoints") or ()) + ) + + +def normalize_model(model: str) -> str: + normalized: Final = model.removeprefix(f"{LlmProviders.SARVAM.value}/").strip() + if not is_realtime_transcription_model(normalized): + raise SarvamProtocolError( + f"unsupported Sarvam realtime model: {model}. Add it to the model cost map with " + f'"mode": "audio_transcription" and "supported_endpoints": ["{REALTIME_ENDPOINT}"]' + ) + return normalized + + +def normalize_language_code(language: str) -> str: + value: Final = language.strip().replace("_", "-").casefold() + if not value: + raise SarvamProtocolError("language must be non-empty") + if value == AUTO_LANGUAGE: + return AUTO_LANGUAGE + documented: Final = _LANGUAGE_BY_CODE.get(value) + if documented is not None: + return documented + mapped: Final = _LANGUAGE_BY_PRIMARY.get(value.split("-", 1)[0]) + if mapped is None: + raise SarvamProtocolError(f"unsupported Sarvam realtime language: {language}") + return mapped + + +@dataclass(frozen=True, slots=True) +class SarvamConnection: + """Everything fixed when the backend socket opens, before the client's first ``session.update``.""" + + model: str + sample_rate: SarvamSampleRate + url: str + + +def _connection_sample_rate(query: str) -> SarvamSampleRate: + declared: Final = tuple(value for key, value in parse_qsl(query) if key == "sample_rate") + if not declared: + return 16_000 + if len(declared) != 1 or not declared[0].isdigit() or int(declared[0]) not in SUPPORTED_SAMPLE_RATES: + raise ValueError("Sarvam realtime supports sample_rate=16000 or sample_rate=8000") + return 8_000 if int(declared[0]) == 8_000 else 16_000 + + +def _is_loopback(hostname: str) -> bool: + if hostname == "localhost": + return True + try: + return ip_address(hostname).is_loopback + except ValueError: + return False + + +def _netloc(hostname: str, port: int | None) -> str: + host: Final = f"[{hostname}]" if ":" in hostname else hostname + return host if port is None else f"{host}:{port}" + + +def build_sarvam_connection(api_base: str | None, model: str) -> SarvamConnection: + """Sarvam fixes ``encoding`` and ``sample_rate`` at connect time, so the rate is read from ``api_base``. + + ``language_code`` is not: the client's language arrives later in ``session.update`` and is applied with a + ``config.update``, so the socket always opens on Sarvam's adaptive ``auto``. + """ + normalized_model: Final = normalize_model(model) + parsed: Final = urlparse((api_base or DEFAULT_SARVAM_REALTIME_API_BASE).strip()) + scheme: Final = _WEBSOCKET_SCHEMES.get(parsed.scheme) + if scheme is None or not parsed.hostname or parsed.username is not None or parsed.password is not None: + raise ValueError("Sarvam api_base must be an absolute wss:// or https:// URL without credentials") + if scheme == "ws" and not _is_loopback(parsed.hostname): + # The subscription key travels as a connection header, so cleartext is only ever for a local test double. + raise ValueError("Sarvam api_base must use wss:// or https:// unless it points at a loopback address") + sample_rate: Final = _connection_sample_rate(parsed.query) + netloc: Final = _netloc(parsed.hostname, parsed.port) + query: Final = urlencode( + ( + ("model", normalized_model), + ("language_code", AUTO_LANGUAGE), + ("encoding", SARVAM_REALTIME_ENCODING), + ("sample_rate", str(sample_rate)), + ) + ) + return SarvamConnection( + model=normalized_model, + sample_rate=sample_rate, + url=urlunparse((scheme, netloc, SARVAM_REALTIME_PATH, "", query, "")), + ) + + +@dataclass(frozen=True, slots=True) +class SarvamSessionUpdate: + language: str | None + endpointing: SarvamEndpointing + + +def _validate_audio_format(update: TranscriptionSessionUpdate, sample_rate: SarvamSampleRate) -> None: + audio_format: Final = update.audio_format + if audio_format is None: + return + if not audio_format.is_pcm16: + raise SarvamProtocolError("Sarvam realtime requires pcm16 input audio") + if audio_format.channels not in (None, 1): + raise SarvamProtocolError("Sarvam realtime requires mono input audio") + if audio_format.rate not in (None, sample_rate): + raise SarvamProtocolError( + f"Sarvam realtime is connected at {sample_rate} Hz; set sample_rate on the deployment api_base to change it" + ) + + +def _parse_endpointing(update: TranscriptionSessionUpdate, current: SarvamEndpointing) -> SarvamEndpointing: + """A later ``session.update`` that changes only the language leaves turn detection out entirely, which must + keep the endpointing the session already runs on rather than reverting it to VAD.""" + if update.turn_detection_disabled: + return "manual" + if update.turn_detection is None: + return current + if update.turn_detection_type != "server_vad": + raise SarvamProtocolError("Sarvam realtime supports server_vad turn detection or null") + return "vad" + + +def parse_session_update( + payload: str, connection: SarvamConnection, endpointing: SarvamEndpointing +) -> SarvamSessionUpdate: + update: Final = parse_transcription_session_update(payload, SarvamProtocolError) + if update.session_type not in (None, "transcription", "realtime"): + raise SarvamProtocolError("Sarvam realtime supports transcription sessions only") + if update.unsupported_transcription_keys: + verbose_logger.warning( + "Sarvam realtime: dropping unsupported transcription settings %s", update.unsupported_transcription_keys + ) + if update.model is not None and normalize_model(update.model) != connection.model: + raise SarvamProtocolError("realtime session model cannot be changed") + _validate_audio_format(update, connection.sample_rate) + return SarvamSessionUpdate( + language=None if update.language is None else normalize_language_code(update.language), + endpointing=_parse_endpointing(update, endpointing), + ) + + +def openai_session( + connection: SarvamConnection, session_id: str, language: str, server_vad: bool +) -> OpenAIRealtimeTranscriptionSession: + return transcription_session( + session_id=session_id, + model=connection.model, + sample_rate=connection.sample_rate, + language=None if language == AUTO_LANGUAGE else language, + server_vad=server_vad, + ) + + +def _control(event: SarvamControlEventType) -> str: + frame: Final[SarvamControlEvent] = {"event": event} + return json.dumps(frame, separators=(",", ":")) + + +_SPEECH_START: Final = _control("speech_start") +_SPEECH_END: Final = _control("speech_end") +_FLUSH: Final = _control("flush") +_END_SESSION: Final = _control("end") + + +@dataclass(slots=True) +class _UtteranceState: + item_id: str + start_emitted: bool = False + emitted_partial: str = "" + stop_emitted: bool = False + completed_emitted: bool = False + + +class _UtteranceTracker: + """Per-``utterance_idx`` emit state, bounded so a long session cannot grow without limit.""" + + def __init__(self, *, limit: int = _UTTERANCE_LIMIT) -> None: + self._states: dict[int, _UtteranceState] = {} # mutable-ok: bounded, insertion-ordered emit state + self._limit: Final = limit + + def state(self, utterance_idx: int) -> _UtteranceState: + existing: Final = self._states.get(utterance_idx) + if existing is not None: + return existing + created: Final = _UtteranceState(item_id=f"item_{uuid.uuid4().hex}") + self._states[utterance_idx] = created + if len(self._states) > self._limit: + del self._states[next(iter(self._states))] + return created + + +def _utterance_index(message: Mapping[str, JsonValue], event: str) -> int: + value: Final = message.get("utterance_idx") + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise SarvamProtocolError(f"{event} event has an invalid utterance_idx") + return value + + +def _transcript_text(message: Mapping[str, JsonValue], event: str) -> str: + text: Final = message.get("text") + if not isinstance(text, str): + raise SarvamProtocolError(f"{event} event has an invalid text") + return text + + +def _audio_seconds(message: Mapping[str, JsonValue]) -> float | None: + value: Final = message.get("audio_duration_s") + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: + return None + return float(value) + + +class SarvamRealtimeConfig(BaseRealtimeConfig): + """Translates between OpenAI realtime transcription sessions and Sarvam's ``/speech-to-text-realtime/ws``. + + One instance per session: ``get_complete_url`` pins the connection and the rest of the methods read it back. + """ + + def __init__(self, *, utterance_limit: int = _UTTERANCE_LIMIT) -> None: + self._utterances: Final = _UtteranceTracker(limit=utterance_limit) + self._connection: SarvamConnection | None = None + self._language: str = AUTO_LANGUAGE + self._endpointing: SarvamEndpointing = "vad" + self._session_id: str | None = None + self._speech_open: bool = False + self._forwarded_audio_bytes: int = 0 + self._reported_seconds: float | None = None + + 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 + key: Final = (api_key or get_secret_str("SARVAM_API_KEY") or "").strip() + if not key: + raise ValueError("api_key is required for Sarvam API calls") + return {**headers, "api-subscription-key": key} # mutable-ok: BaseRealtimeConfig returns a plain header dict + + def get_complete_url(self, api_base: str | None, model: str, api_key: str | None = None) -> str: + connection: Final = build_sarvam_connection(api_base, model) + self._connection = connection + return connection.url + + def transform_realtime_request( + self, + message: str, + model: str, + session_configuration_request: str | None = None, + ) -> tuple[str | bytes, ...]: + request: Final = json_object(message, SarvamProtocolError) + match request.get("type"): + case "session.update" | "transcription_session.update": + return self._configure(message) + case "input_audio_buffer.append": + return self._append_audio(request) + case "input_audio_buffer.commit": + return self._finalize_utterance() + case "input_audio_buffer.end": + return (*self._finalize_utterance(), _END_SESSION) + case "input_audio_buffer.clear": + # Sarvam has no buffered-audio discard; audio already forwarded cannot be taken back. + return () + case unsupported: + verbose_logger.debug("Sarvam realtime: dropping unsupported client event %s", unsupported) + return () + + def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None: + """Sarvam's own ``audio_duration_s`` is the billed quantity; the forwarded audio only covers a session + that ended before ``session.end`` arrived (a client that drops the socket mid-utterance).""" + connection: Final = self._connection + if connection is None: + return None + reported: Final = self._reported_seconds + seconds: Final = ( + reported if reported is not None else self._forwarded_audio_bytes / (connection.sample_rate * 2) + ) + self._reported_seconds = None + self._forwarded_audio_bytes = 0 + return duration_usage(seconds) if seconds > 0 else None + + 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 + response: 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 response + + def _require_connection(self) -> SarvamConnection: + connection: Final = self._connection + if connection is None: + raise SarvamProtocolError("the Sarvam realtime session was used before its url was built") + return connection + + def _configure(self, message: str) -> tuple[str, ...]: + update: Final = parse_session_update(message, self._require_connection(), self._endpointing) + language: Final = update.language or self._language + language_changed: Final = language != self._language + endpointing_changed: Final = update.endpointing != self._endpointing + self._language = language + self._endpointing = update.endpointing + if language_changed and endpointing_changed: + both: Final[SarvamConfigUpdate] = { + "event": "config.update", + "language_code": language, + "endpointing": update.endpointing, + } + return (json.dumps(both, separators=(",", ":")),) + if language_changed: + language_only: Final[SarvamConfigUpdate] = {"event": "config.update", "language_code": language} + return (json.dumps(language_only, separators=(",", ":")),) + if endpointing_changed: + endpointing_only: Final[SarvamConfigUpdate] = { + "event": "config.update", + "endpointing": update.endpointing, + } + return (json.dumps(endpointing_only, separators=(",", ":")),) + return () + + def _append_audio(self, request: Mapping[str, JsonValue]) -> tuple[str, ...]: + audio: Final = decode_pcm16_append(request.get("audio"), None, SarvamProtocolError) + if not audio: + return () + self._forwarded_audio_bytes += len(audio) + frame: Final[SarvamAudioInput] = {"event": "audio_input", "audio": base64.b64encode(audio).decode("ascii")} + return (*self._open_speech(), json.dumps(frame, separators=(",", ":"))) + + def _open_speech(self) -> tuple[str, ...]: + """Manual endpointing makes the client responsible for utterance boundaries, which OpenAI clients signal + by appending audio and committing rather than with explicit speech events.""" + if self._endpointing != "manual" or self._speech_open: + return () + self._speech_open = True + return (_SPEECH_START,) + + def _finalize_utterance(self) -> tuple[str, ...]: + if self._endpointing != "manual": + verbose_logger.debug("Sarvam realtime: input_audio_buffer.commit is a no-op under VAD endpointing") + return () + if not self._speech_open: + return () + self._speech_open = False + return (_SPEECH_END, _FLUSH) + + def _backend_events(self, payload: str) -> tuple[OpenAIRealtimeEvents, ...]: + frame: Final = json_object(payload, SarvamProtocolError) + match frame.get("event"): + case "session.begin": + return (self._session_created(frame),) + case "vad.speech_start": + return self._speech_started(_utterance_index(frame, "vad.speech_start")) + case "vad.speech_end": + return self._speech_stopped(_utterance_index(frame, "vad.speech_end")) + case "transcript.partial": + return self._partial(frame) + case "transcript.final": + return self._final(frame) + case "config.updated": + return self._session_updated() + case "session.end": + self._reported_seconds = _audio_seconds(frame) + return () + case "error": + return (error_event(self._error_message(frame)),) + case ignored: + verbose_logger.debug("Sarvam realtime: no client event for backend event %s", ignored) + return () + + def _session_created(self, frame: Mapping[str, JsonValue]) -> OpenAIRealtimeEvents: + request_id: Final = json_string(frame.get("request_id"), "session.begin request_id", SarvamProtocolError) + if not request_id: + raise SarvamProtocolError("session.begin event has an invalid request_id") + self._session_id = request_id + return transcription_session_created_event(self._openai_session(request_id)) + + def _session_updated(self) -> tuple[OpenAIRealtimeEvents, ...]: + """Sarvam opens the socket on its own defaults and acknowledges each ``config.update`` separately, so a + client that configures the session after ``session.created`` only learns what took effect from here.""" + session_id: Final = self._session_id + if session_id is None: + return () + return (transcription_session_updated_event(self._openai_session(session_id)),) + + def _openai_session(self, session_id: str) -> OpenAIRealtimeTranscriptionSession: + return openai_session(self._require_connection(), session_id, self._language, self._endpointing == "vad") + + def _speech_started(self, utterance_idx: int) -> tuple[OpenAIRealtimeEvents, ...]: + state: Final = self._utterances.state(utterance_idx) + if state.start_emitted: + return () + state.start_emitted = True + return (speech_event("input_audio_buffer.speech_started", state.item_id),) + + def _speech_stopped(self, utterance_idx: int) -> tuple[OpenAIRealtimeEvents, ...]: + state: Final = self._utterances.state(utterance_idx) + if state.stop_emitted: + return () + state.stop_emitted = True + return (*self._speech_started(utterance_idx), speech_event("input_audio_buffer.speech_stopped", state.item_id)) + + def _partial(self, frame: Mapping[str, JsonValue]) -> tuple[OpenAIRealtimeEvents, ...]: + utterance_idx: Final = _utterance_index(frame, "transcript.partial") + text: Final = _transcript_text(frame, "transcript.partial") + state: Final = self._utterances.state(utterance_idx) + if state.completed_emitted: + return () + started: Final = self._speech_started(utterance_idx) + # Sarvam revises partials mid-utterance, so a partial can drop words it previously emitted. Deltas can + # only ever append, so a revision emits nothing and the emitted prefix is kept: re-sending from a shorter + # revision would duplicate words on the client. The authoritative text arrives in the completed event. + if not text.startswith(state.emitted_partial) or text == state.emitted_partial: + return started + delta: Final = text[len(state.emitted_partial) :] + state.emitted_partial = text + return (*started, delta_event(state.item_id, delta)) + + def _final(self, frame: Mapping[str, JsonValue]) -> tuple[OpenAIRealtimeEvents, ...]: + utterance_idx: Final = _utterance_index(frame, "transcript.final") + text: Final = _transcript_text(frame, "transcript.final") + state: Final = self._utterances.state(utterance_idx) + if state.completed_emitted: + return () + stopped: Final = self._speech_stopped(utterance_idx) + state.completed_emitted = True + return (*stopped, completed_event(state.item_id, text, None)) + + def _error_message(self, frame: Mapping[str, JsonValue]) -> str: + code: Final = json_string(frame.get("code"), "error code", SarvamProtocolError) or "unknown" + message: Final = json_string(frame.get("message"), "error message", SarvamProtocolError) or "" + return f"Sarvam realtime error [{code}]{f': {message}' if message else ''}" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b5106670a2bd..37bf746d2520 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -58886,6 +58886,28 @@ "mode": "embedding", "source": "https://docs.libertai.io/apis/text/" }, + "sarvam/saaras:v3-realtime": { + "litellm_provider": "sarvam", + "metadata": { + "note": "Sarvam prices speech to text in INR per hour, billed per second; this registry is USD only, so sessions log their billed audio duration and no cost" + }, + "mode": "audio_transcription", + "source": "https://docs.sarvam.ai/api/pricing", + "supported_endpoints": [ + "/v1/realtime" + ] + }, + "sarvam/saaras:v4": { + "litellm_provider": "sarvam", + "metadata": { + "note": "Sarvam prices speech to text in INR per hour, billed per second; this registry is USD only, so sessions log their billed audio duration and no cost" + }, + "mode": "audio_transcription", + "source": "https://docs.sarvam.ai/api/pricing", + "supported_endpoints": [ + "/v1/realtime" + ] + }, "sarvam/sarvam-m": { "cache_creation_input_token_cost": 0, "cache_creation_input_token_cost_above_1hr": 0, diff --git a/litellm/proxy/dev_config.yaml b/litellm/proxy/dev_config.yaml index 1fc9e09b8979..56f755894a4c 100644 --- a/litellm/proxy/dev_config.yaml +++ b/litellm/proxy/dev_config.yaml @@ -197,6 +197,12 @@ model_list: model: openai/gpt-4o-mini api_key: os.environ/OPENAI_API_KEY + # ---------- Sarvam realtime speech to text ---------- + - model_name: sarvam-stt + litellm_params: + model: sarvam/saaras:v3-realtime + api_key: os.environ/SARVAM_API_KEY + general_settings: master_key: os.environ/LITELLM_MASTER_KEY # Opt-in: let CheckBatchCost track cost for unmanaged batches created with a raw diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 599b1a762498..fc2b69a05346 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -2270,6 +2270,12 @@ class OpenAIRealtimeTranscriptionSessionCreated(TypedDict): session: ReadOnly[OpenAIRealtimeTranscriptionSession] +class OpenAIRealtimeTranscriptionSessionUpdated(TypedDict): + type: ReadOnly[Literal["session.updated"]] + event_id: ReadOnly[str] + session: ReadOnly[OpenAIRealtimeTranscriptionSession] + + class OpenAIRealtimeInputAudioTranscriptionDelta(TypedDict): type: ReadOnly[Literal["conversation.item.input_audio_transcription.delta"]] event_id: ReadOnly[str] @@ -2349,6 +2355,7 @@ class OpenAIRealtimeEventTypes(Enum): | OpenAIRealtimeInputAudioTranscriptionDelta | OpenAIRealtimeInputAudioTranscriptionCompleted | OpenAIRealtimeTranscriptionSessionCreated + | OpenAIRealtimeTranscriptionSessionUpdated | OpenAIRealtimeErrorEvent ) diff --git a/litellm/types/llms/sarvam.py b/litellm/types/llms/sarvam.py new file mode 100644 index 000000000000..3b45311aa98a --- /dev/null +++ b/litellm/types/llms/sarvam.py @@ -0,0 +1,22 @@ +from typing import Literal, TypeAlias + +from typing_extensions import NotRequired, ReadOnly, TypedDict + +SarvamEndpointing: TypeAlias = Literal["vad", "manual"] +SarvamSampleRate: TypeAlias = Literal[8000, 16000] +SarvamControlEventType: TypeAlias = Literal["speech_start", "speech_end", "flush", "end"] + + +class SarvamAudioInput(TypedDict): + event: ReadOnly[Literal["audio_input"]] + audio: ReadOnly[str] + + +class SarvamControlEvent(TypedDict): + event: ReadOnly[SarvamControlEventType] + + +class SarvamConfigUpdate(TypedDict): + event: ReadOnly[Literal["config.update"]] + language_code: NotRequired[ReadOnly[str]] + endpointing: NotRequired[ReadOnly[SarvamEndpointing]] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 346181d9d9e1..5fe5c11ad722 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4286,6 +4286,7 @@ class LlmProviders(str, Enum): CURSOR = "cursor" BEDROCK_MANTLE = "bedrock_mantle" GDC = "gdc" + SARVAM = "sarvam" # Create a set of all provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index df2a6adbe1f1..8eb074419d09 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9528,6 +9528,10 @@ def get_provider_realtime_config( from litellm.llms.meta.realtime.transformation import MetaRealtimeConfig return MetaRealtimeConfig() + if LlmProviders.SARVAM == provider: + from litellm.llms.sarvam.realtime.transformation import SarvamRealtimeConfig + + return SarvamRealtimeConfig() return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b5106670a2bd..37bf746d2520 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -58886,6 +58886,28 @@ "mode": "embedding", "source": "https://docs.libertai.io/apis/text/" }, + "sarvam/saaras:v3-realtime": { + "litellm_provider": "sarvam", + "metadata": { + "note": "Sarvam prices speech to text in INR per hour, billed per second; this registry is USD only, so sessions log their billed audio duration and no cost" + }, + "mode": "audio_transcription", + "source": "https://docs.sarvam.ai/api/pricing", + "supported_endpoints": [ + "/v1/realtime" + ] + }, + "sarvam/saaras:v4": { + "litellm_provider": "sarvam", + "metadata": { + "note": "Sarvam prices speech to text in INR per hour, billed per second; this registry is USD only, so sessions log their billed audio duration and no cost" + }, + "mode": "audio_transcription", + "source": "https://docs.sarvam.ai/api/pricing", + "supported_endpoints": [ + "/v1/realtime" + ] + }, "sarvam/sarvam-m": { "cache_creation_input_token_cost": 0, "cache_creation_input_token_cost_above_1hr": 0, diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 38aad3ace1c7..364141183288 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1257,6 +1257,19 @@ def test_get_provider_rerank_config(): assert isinstance(config, HostedVLLMRerankConfig) +def test_get_provider_realtime_config_serves_sarvam_speech_to_text(): + """Sarvam realtime transcription only reaches /v1/realtime when the provider registry hands back its + config; without this the endpoint rejects every sarvam deployment as an unsupported model.""" + from litellm.llms.sarvam.realtime.transformation import SarvamRealtimeConfig + from litellm.utils import LlmProviders + + config = ProviderConfigManager.get_provider_realtime_config( + model="saaras:v3-realtime", provider=LlmProviders.SARVAM + ) + + assert isinstance(config, SarvamRealtimeConfig) + + def test_get_provider_text_to_speech_config_vertex_gemini_skips_cloud_tts(): """Regression for LIT-6501: mapping vertex Gemini TTS params through Google Cloud TTS dropped response_format before the speech_to_completion bridge could honor it.""" diff --git a/tests/unit/llms/sarvam/__init__.py b/tests/unit/llms/sarvam/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/unit/llms/sarvam/realtime/__init__.py b/tests/unit/llms/sarvam/realtime/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/unit/llms/sarvam/realtime/test_sarvam_realtime_transformation.py b/tests/unit/llms/sarvam/realtime/test_sarvam_realtime_transformation.py new file mode 100644 index 000000000000..0183ecbad95e --- /dev/null +++ b/tests/unit/llms/sarvam/realtime/test_sarvam_realtime_transformation.py @@ -0,0 +1,555 @@ +import base64 +import json +from typing import Final +from unittest.mock import MagicMock + +import pytest + +from litellm.llms.sarvam.realtime.transformation import ( + DEFAULT_SARVAM_REALTIME_MODEL, + SUPPORTED_LANGUAGE_CODES, + SarvamProtocolError, + SarvamRealtimeConfig, + build_sarvam_connection, + normalize_language_code, +) +from litellm.types.realtime import RealtimeResponseTransformInput +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +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, +} +MODEL: Final = f"sarvam/{DEFAULT_SARVAM_REALTIME_MODEL}" + +pytestmark: Final = pytest.mark.usefixtures("local_model_cost_map") +ONE_SECOND_OF_SILENCE: Final = b"\x00\x00" * 16_000 + + +def _session_update( + rate: int | None = 16_000, + turn_detection: object = "server_vad", + language: str | None = None, + model: str | None = None, +) -> str: + """``turn_detection="omit"`` leaves the key out entirely, the way a client that only changes its language + sends a partial update; ``None`` sends an explicit null, which disables turn detection.""" + transcription: Final[dict[str, str]] = { + **({"model": model} if model is not None else {}), + **({"language": language} if language is not None else {}), + } + return json.dumps( + { + "type": "session.update", + "session": { + "type": "transcription", + "audio": { + "input": { + **({"format": {"type": "audio/pcm", "rate": rate}} if rate is not None else {}), + **( + {} + if turn_detection == "omit" + else {"turn_detection": None if turn_detection is None else {"type": turn_detection}} + ), + "transcription": transcription, + } + }, + }, + } + ) + + +def _append(audio: bytes) -> str: + return json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(audio).decode("ascii")}) + + +def _client_event(event_type: str) -> str: + return json.dumps({"type": event_type}) + + +def _configured( + *, api_base: str | None = None, turn_detection: object = "server_vad", rate: int = 16_000 +) -> SarvamRealtimeConfig: + config: Final = SarvamRealtimeConfig() + config.get_complete_url(api_base, MODEL) + config.validate_environment({}, MODEL, api_key="secret-key") + config.transform_realtime_request(_session_update(rate=rate, turn_detection=turn_detection), MODEL) + return config + + +def _backend(stt: SarvamRealtimeConfig, **frame: object) -> list[dict[str, object]]: + response: Final = stt.transform_realtime_response(json.dumps(frame), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT)[ + "response" + ] + assert isinstance(response, list) + return response + + +def _sent(stt: SarvamRealtimeConfig, message: str) -> list[dict[str, object]]: + return [json.loads(frame) for frame in stt.transform_realtime_request(message, MODEL)] + + +def test_connection_url_pins_the_fields_sarvam_fixes_at_connect_time(): + connection = build_sarvam_connection(None, MODEL) + + assert connection.url.startswith("wss://api.sarvam.ai/speech-to-text-realtime/ws?") + assert connection.sample_rate == 16_000 + query = dict(part.split("=", 1) for part in connection.url.split("?", 1)[1].split("&")) + assert query["language_code"] == "auto" + assert query["encoding"] == "linear16" + assert query["sample_rate"] == "16000" + assert query["model"] == "saaras%3Av3-realtime" + + +@pytest.mark.parametrize( + ("api_base", "expected_rate"), + [ + ("https://api.sarvam.ai", 16_000), + ("wss://api.sarvam.ai?sample_rate=8000", 8_000), + ("ws://localhost:8123?sample_rate=16000", 16_000), + ], +) +def test_api_base_supplies_the_connection_only_sample_rate(api_base: str, expected_rate: int): + connection = build_sarvam_connection(api_base, MODEL) + + assert connection.sample_rate == expected_rate + assert f"sample_rate={expected_rate}" in connection.url + + +@pytest.mark.parametrize( + "api_base", + [ + "wss://api.sarvam.ai?sample_rate=24000", + "wss://api.sarvam.ai?sample_rate=16000&sample_rate=8000", + "wss://api.sarvam.ai?sample_rate=sixteen", + ], +) +def test_unsupported_connection_sample_rate_is_rejected(api_base: str): + with pytest.raises(ValueError, match="sample_rate"): + build_sarvam_connection(api_base, MODEL) + + +@pytest.mark.parametrize("api_base", ["ftp://api.sarvam.ai", "wss://user:pass@api.sarvam.ai", "not-a-url"]) +def test_unusable_api_base_is_rejected(api_base: str): + with pytest.raises(ValueError, match="api_base"): + build_sarvam_connection(api_base, MODEL) + + +@pytest.mark.parametrize("api_base", ["ws://stt.internal.example", "http://198.51.100.7:8123"]) +def test_cleartext_to_a_remote_host_is_rejected_because_the_key_is_a_connection_header(api_base: str): + with pytest.raises(ValueError, match="loopback"): + build_sarvam_connection(api_base, MODEL) + + +@pytest.mark.parametrize( + ("api_base", "expected_netloc"), + [ + ("ws://localhost:8123", "localhost:8123"), + ("ws://127.0.0.1:8123", "127.0.0.1:8123"), + ("ws://[::1]:8123", "[::1]:8123"), + ], +) +def test_a_loopback_test_double_stays_reachable_over_cleartext(api_base: str, expected_netloc: str): + url = build_sarvam_connection(api_base, MODEL).url + + assert url.startswith(f"ws://{expected_netloc}/speech-to-text-realtime/ws?") + + +def test_a_model_the_registry_does_not_serve_on_the_realtime_endpoint_is_rejected(): + with pytest.raises(SarvamProtocolError, match="model"): + build_sarvam_connection(None, "sarvam/whisper-large") + + +def test_a_model_registered_for_the_realtime_endpoint_is_accepted_without_a_code_change(): + """Support reads the model registry, so a new Sarvam realtime model ships as a cost map entry.""" + import litellm + + litellm.register_model( + { + "sarvam/saaras:v9-realtime": { + "litellm_provider": "sarvam", + "mode": "audio_transcription", + "supported_endpoints": ["/v1/realtime"], + } + } + ) + + assert build_sarvam_connection(None, "sarvam/saaras:v9-realtime").model == "saaras:v9-realtime" + + +def test_a_sarvam_chat_model_is_not_served_on_the_realtime_endpoint(): + """The registry entry has to say audio_transcription on /v1/realtime, not merely exist under sarvam/.""" + with pytest.raises(SarvamProtocolError, match="model"): + build_sarvam_connection(None, "sarvam/sarvam-m") + + +@pytest.mark.parametrize( + ("requested", "expected"), + [("hi", "hi-IN"), ("hi-IN", "hi-IN"), ("HI-in", "hi-IN"), ("en_IN", "en-IN"), ("auto", "auto")], +) +def test_language_normalization_maps_onto_the_documented_codes(requested: str, expected: str): + assert normalize_language_code(requested) == expected + assert expected == "auto" or expected in SUPPORTED_LANGUAGE_CODES + + +@pytest.mark.parametrize("requested", ["", " ", "klingon", "zz-ZZ"]) +def test_unsupported_language_is_rejected(requested: str): + with pytest.raises(SarvamProtocolError, match="language"): + normalize_language_code(requested) + + +def test_api_key_travels_in_sarvams_subscription_header(): + headers = SarvamRealtimeConfig().validate_environment({"x-existing": "kept"}, MODEL, api_key=" secret-key ") + + assert headers == {"x-existing": "kept", "api-subscription-key": "secret-key"} + + +@pytest.mark.parametrize("api_key", [None, " "]) +def test_missing_api_key_is_rejected(api_key: str | None, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("SARVAM_API_KEY", raising=False) + + with pytest.raises(ValueError, match="api_key"): + SarvamRealtimeConfig().validate_environment({}, MODEL, api_key=api_key) + + +def test_session_language_is_applied_with_a_config_update_rather_than_at_connect_time(): + config = _configured() + + assert _sent(config, _session_update(language="hi")) == [{"event": "config.update", "language_code": "hi-IN"}] + + +def test_repeating_the_same_session_settings_sends_nothing(): + config = _configured() + config.transform_realtime_request(_session_update(language="hi"), MODEL) + + assert config.transform_realtime_request(_session_update(language="hi-IN"), MODEL) == () + + +def test_disabling_turn_detection_switches_sarvam_to_manual_endpointing(): + config = _configured() + + assert _sent(config, _session_update(turn_detection=None)) == [{"event": "config.update", "endpointing": "manual"}] + + +@pytest.mark.parametrize( + ("update", "expected_message"), + [ + (_session_update(rate=24_000), "16000 Hz"), + (_session_update(turn_detection="semantic_vad"), "turn detection"), + (_session_update(model="sarvam/saaras:v4"), "cannot be changed"), + ], +) +def test_client_session_settings_sarvam_cannot_honor_are_rejected(update: str, expected_message: str): + config = _configured() + + with pytest.raises(SarvamProtocolError, match=expected_message): + config.transform_realtime_request(update, MODEL) + + +def test_audio_is_forwarded_as_a_sarvam_audio_input_frame(): + config = _configured() + + frames = _sent(config, _append(ONE_SECOND_OF_SILENCE)) + + assert len(frames) == 1 + assert frames[0]["event"] == "audio_input" + assert base64.b64decode(str(frames[0]["audio"])) == ONE_SECOND_OF_SILENCE + + +def test_commit_is_a_no_op_under_vad_endpointing(): + config = _configured() + config.transform_realtime_request(_append(ONE_SECOND_OF_SILENCE), MODEL) + + assert config.transform_realtime_request(_client_event("input_audio_buffer.commit"), MODEL) == () + + +def test_manual_endpointing_brackets_each_utterance_with_speech_events(): + config = _configured(turn_detection=None) + + first = _sent(config, _append(ONE_SECOND_OF_SILENCE)) + second = _sent(config, _append(ONE_SECOND_OF_SILENCE)) + committed = _sent(config, _client_event("input_audio_buffer.commit")) + next_utterance = _sent(config, _append(ONE_SECOND_OF_SILENCE)) + + assert [frame["event"] for frame in first] == ["speech_start", "audio_input"] + assert [frame["event"] for frame in second] == ["audio_input"] + assert [frame["event"] for frame in committed] == ["speech_end", "flush"] + assert [frame["event"] for frame in next_utterance] == ["speech_start", "audio_input"] + + +def test_committing_without_audio_sends_nothing_under_manual_endpointing(): + config = _configured(turn_detection=None) + + assert config.transform_realtime_request(_client_event("input_audio_buffer.commit"), MODEL) == () + + +def test_buffer_end_closes_the_sarvam_session(): + config = _configured(turn_detection=None) + config.transform_realtime_request(_append(ONE_SECOND_OF_SILENCE), MODEL) + + assert [frame["event"] for frame in _sent(config, _client_event("input_audio_buffer.end"))] == [ + "speech_end", + "flush", + "end", + ] + + +def test_buffer_clear_is_dropped_because_sarvam_cannot_discard_sent_audio(): + config = _configured() + config.transform_realtime_request(_append(ONE_SECOND_OF_SILENCE), MODEL) + + assert config.transform_realtime_request(_client_event("input_audio_buffer.clear"), MODEL) == () + + +def test_session_begin_becomes_an_openai_transcription_session(): + config = _configured(api_base="wss://api.sarvam.ai?sample_rate=8000", rate=8_000) + config.transform_realtime_request(_session_update(rate=8_000, language="ta"), MODEL) + + events = _backend(config, event="session.begin", request_id="req-42", config={}) + + assert len(events) == 1 + session = events[0]["session"] + assert events[0]["type"] == "session.created" + assert session["id"] == "req-42" + assert session["type"] == "transcription" + assert session["audio"]["input"]["format"] == {"type": "audio/pcm", "rate": 8_000} + assert session["audio"]["input"]["transcription"] == {"model": "saaras:v3-realtime", "language": "ta-IN"} + assert session["audio"]["input"]["turn_detection"] == {"type": "server_vad"} + + +def test_a_later_update_that_only_changes_the_language_keeps_manual_endpointing(): + """Turn detection left out of a partial update means unchanged. Reverting to VAD here would silently turn + the client's audio buffer commits into no-ops.""" + config = _configured(turn_detection=None) + + language_only = _sent(config, _session_update(turn_detection="omit", language="ta")) + config.transform_realtime_request(_append(ONE_SECOND_OF_SILENCE), MODEL) + committed = _sent(config, _client_event("input_audio_buffer.commit")) + + assert language_only == [{"event": "config.update", "language_code": "ta-IN"}] + assert [frame["event"] for frame in committed] == ["speech_end", "flush"] + + +def test_applied_settings_reach_the_client_as_a_session_updated_event(): + """Sarvam opens on its own defaults, so a client that configures the session after session.created only + learns the language and turn detection that took effect from the config.updated acknowledgement.""" + config = _configured() + _backend(config, event="session.begin", request_id="req-42", config={}) + config.transform_realtime_request(_session_update(language="hi", turn_detection=None), MODEL) + + events = _backend(config, event="config.updated", applied=["language_code", "endpointing"]) + + assert [event["type"] for event in events] == ["session.updated"] + session = events[0]["session"] + assert session["id"] == "req-42" + assert session["audio"]["input"]["transcription"]["language"] == "hi-IN" + assert session["audio"]["input"]["turn_detection"] is None + + +def test_an_acknowledgement_before_the_session_exists_is_dropped(): + config = _configured() + + assert _backend(config, event="config.updated", applied=["language_code"]) == [] + + +def test_session_begin_reports_manual_endpointing_as_disabled_turn_detection(): + config = _configured(turn_detection=None) + + events = _backend(config, event="session.begin", request_id="req-42", config={}) + + assert events[0]["session"]["audio"]["input"]["turn_detection"] is None + + +def test_session_begin_without_a_request_id_is_rejected(): + config = _configured() + + with pytest.raises(SarvamProtocolError, match="request_id"): + _backend(config, event="session.begin", config={}) + + +def test_vad_events_become_openai_speech_events_once_per_utterance(): + config = _configured() + + started = _backend(config, event="vad.speech_start", utterance_idx=0, confidence=0.92) + repeated = _backend(config, event="vad.speech_start", utterance_idx=0, confidence=0.93) + stopped = _backend(config, event="vad.speech_end", utterance_idx=0, confidence=0.88) + + assert [event["type"] for event in started] == ["input_audio_buffer.speech_started"] + assert repeated == [] + assert [event["type"] for event in stopped] == ["input_audio_buffer.speech_stopped"] + assert stopped[0]["item_id"] == started[0]["item_id"] + + +def test_each_utterance_gets_its_own_item_id(): + config = _configured() + + first = _backend(config, event="vad.speech_start", utterance_idx=0, confidence=0.9) + second = _backend(config, event="vad.speech_start", utterance_idx=1, confidence=0.9) + + assert first[0]["item_id"] != second[0]["item_id"] + + +def test_growing_partials_become_incremental_deltas(): + config = _configured() + + first = _backend(config, event="transcript.partial", utterance_idx=0, text="Four score") + second = _backend(config, event="transcript.partial", utterance_idx=0, text="Four score and seven") + + assert [event["type"] for event in first] == [ + "input_audio_buffer.speech_started", + "conversation.item.input_audio_transcription.delta", + ] + assert first[-1]["delta"] == "Four score" + assert [event["delta"] for event in second] == [" and seven"] + + +def test_a_revised_partial_never_replays_words_the_client_already_has(): + """Sarvam revises partials mid-utterance: a shorter revision must not make the next growth re-send a + prefix the client already rendered. Regression for text observed on a live `saaras:v3-realtime` session.""" + config = _configured() + + _backend(config, event="transcript.partial", utterance_idx=0, text="Four score and seven years ago") + revised = _backend(config, event="transcript.partial", utterance_idx=0, text="Four score") + regrown = _backend(config, event="transcript.partial", utterance_idx=0, text="Four score and seven years ago, our") + + assert revised == [] + assert [event["delta"] for event in regrown] == [", our"] + + +def test_unchanged_partials_emit_nothing(): + config = _configured() + _backend(config, event="transcript.partial", utterance_idx=0, text="Four score") + + assert _backend(config, event="transcript.partial", utterance_idx=0, text="Four score") == [] + + +def test_final_transcript_completes_the_item_with_the_authoritative_text(): + config = _configured() + _backend(config, event="transcript.partial", utterance_idx=0, text="Four score and seven") + + events = _backend(config, event="transcript.final", utterance_idx=0, text="Four score and seven years ago.") + + assert [event["type"] for event in events] == [ + "input_audio_buffer.speech_stopped", + "conversation.item.input_audio_transcription.completed", + ] + assert events[-1]["transcript"] == "Four score and seven years ago." + + +def test_a_completed_utterance_ignores_later_frames_for_the_same_index(): + config = _configured() + _backend(config, event="transcript.final", utterance_idx=0, text="done") + + assert _backend(config, event="transcript.final", utterance_idx=0, text="again") == [] + assert _backend(config, event="transcript.partial", utterance_idx=0, text="done and more") == [] + + +@pytest.mark.parametrize("frame", [{"utterance_idx": -1}, {"utterance_idx": "0"}, {}]) +def test_backend_events_without_a_usable_utterance_index_are_rejected(frame: dict[str, object]): + config = _configured() + + with pytest.raises(SarvamProtocolError, match="utterance_idx"): + _backend(config, event="transcript.partial", text="hello", **frame) + + +def test_session_end_bills_the_duration_sarvam_reports(): + config = _configured() + config.transform_realtime_request(_append(ONE_SECOND_OF_SILENCE), MODEL) + + assert _backend(config, event="session.end", request_id="req-42", audio_duration_s=17.58) == [] + assert config.unbilled_usage_on_session_close(MODEL) == {"type": "duration", "seconds": 17.58} + + +def test_a_session_that_ends_before_sarvam_reports_bills_the_audio_that_was_forwarded(): + config = _configured() + config.transform_realtime_request(_append(ONE_SECOND_OF_SILENCE * 3), MODEL) + + assert config.unbilled_usage_on_session_close(MODEL) == {"type": "duration", "seconds": 3.0} + + +def test_a_session_with_no_audio_bills_nothing(): + config = _configured() + + assert config.unbilled_usage_on_session_close(MODEL) is None + + +def test_usage_is_only_reported_once(): + config = _configured() + _backend(config, event="session.end", request_id="req-42", audio_duration_s=17.58) + config.unbilled_usage_on_session_close(MODEL) + + assert config.unbilled_usage_on_session_close(MODEL) is None + + +def test_provider_errors_reach_the_client_as_openai_error_events(): + config = _configured() + + events = _backend(config, event="error", code="invalid_config", is_fatal=False, message="language not supported") + + assert events[0]["type"] == "error" + assert "invalid_config" in events[0]["error"]["message"] + assert "language not supported" in events[0]["error"]["message"] + + +@pytest.mark.parametrize("event", ["pong", "something.new"]) +def test_backend_events_without_an_openai_equivalent_are_dropped(event: str): + config = _configured() + + assert _backend(config, event=event) == [] + + +def test_realtime_requests_before_the_socket_url_is_built_are_rejected(): + config = SarvamRealtimeConfig() + + with pytest.raises(SarvamProtocolError, match="url"): + config.transform_realtime_request(_session_update(), MODEL) + + +def test_the_provider_registry_serves_the_sarvam_realtime_config(): + config = ProviderConfigManager.get_provider_realtime_config( + model=DEFAULT_SARVAM_REALTIME_MODEL, provider=LlmProviders.SARVAM + ) + + assert isinstance(config, SarvamRealtimeConfig) + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize("model", ["saaras:v3-realtime", "saaras:v4"]) +def test_the_realtime_models_are_registered_as_transcription_models(model: str): + """Sarvam prices in INR, which this registry cannot hold, so the entry carries no rate. It still has to + describe the model, or the endpoint cannot resolve it and sessions log neither duration nor cost.""" + import litellm + + registry = litellm.get_model_info(model=f"sarvam/{model}", custom_llm_provider="sarvam") + + assert registry["mode"] == "audio_transcription" + assert "/v1/realtime" in (registry.get("supported_endpoints") or ()) + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_a_priced_deployment_bills_the_seconds_the_session_reported(): + """An operator who converts Sarvam's INR rate themselves gets that rate applied to the reported audio.""" + import litellm + from litellm.cost_calculator import handle_realtime_transcription_cost_calculation + + litellm.register_model( + {"sarvam/saaras:v3-realtime": {"litellm_provider": "sarvam", "input_cost_per_second": 0.00009}} + ) + completed = [ + { + "type": "conversation.item.input_audio_transcription.completed", + "usage": {"type": "duration", "seconds": 18.3}, + } + ] + + cost = handle_realtime_transcription_cost_calculation( + results=completed, custom_llm_provider="sarvam", litellm_model_name="saaras:v3-realtime" + ) + + assert cost == pytest.approx(18.3 * 0.00009)