feat(soniox): add soniox audio transcription integration (#29508)

This commit is contained in:
Dan Lemon 2026-06-05 12:28:33 +02:00 • committed by GitHub
parent 5c79b08525
commit 38f2660c64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2968 additions and 3 deletions

View file

@ -612,6 +612,7 @@ cerebras_models: Set = set()
galadriel_models: Set = set()
nvidia_nim_models: Set = set()
nvidia_riva_models: Set = set()
soniox_models: Set = set()
sambanova_models: Set = set()
sambanova_embedding_models: Set = set()
novita_models: Set = set()
@ -844,6 +845,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
nvidia_nim_models.add(key)
elif value.get("litellm_provider") == "nvidia_riva":
nvidia_riva_models.add(key)
elif value.get("litellm_provider") == "soniox":
soniox_models.add(key)
elif value.get("litellm_provider") == "sambanova":
sambanova_models.add(key)
elif value.get("litellm_provider") == "sambanova-embedding-models":
@ -1009,6 +1012,7 @@ model_list = list(
| galadriel_models
| nvidia_nim_models
| nvidia_riva_models
| soniox_models
| sambanova_models
| azure_text_models
| novita_models
@ -1109,6 +1113,7 @@ models_by_provider: dict = {
"galadriel": galadriel_models,
"nvidia_nim": nvidia_nim_models,
"nvidia_riva": nvidia_riva_models,
"soniox": soniox_models,
"sambanova": sambanova_models | sambanova_embedding_models,
"novita": novita_models,
"nebius": nebius_models | nebius_embedding_models,

View file

@ -321,6 +321,7 @@ LLM_CONFIG_NAMES = (
"LemonadeChatConfig",
"SnowflakeEmbeddingConfig",
"AmazonNovaChatConfig",
"SonioxAudioTranscriptionConfig",
)
# Types that support lazy loading via _lazy_import_types
@ -1195,6 +1196,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.amazon_nova.chat.transformation",
"AmazonNovaChatConfig",
),
"SonioxAudioTranscriptionConfig": (
".llms.soniox.audio_transcription.transformation",
"SonioxAudioTranscriptionConfig",
),
}
# Import map for utils module lazy imports

View file

@ -659,6 +659,11 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
or get_secret_str("NVIDIA_RIVA_API_KEY")
or get_secret_str("NVIDIA_NIM_API_KEY")
)
elif custom_llm_provider == "soniox":
api_base = (
api_base or get_secret_str("SONIOX_API_BASE") or "https://api.soniox.com"
)
dynamic_api_key = api_key or get_secret_str("SONIOX_API_KEY")
elif custom_llm_provider == "cerebras":
api_base = (
api_base or get_secret("CEREBRAS_API_BASE") or "https://api.cerebras.ai/v1"

View file

@ -341,6 +341,11 @@ def get_supported_openai_params( # noqa: PLR0915
return ElevenLabsAudioTranscriptionConfig().get_supported_openai_params(
model=model
)
elif custom_llm_provider == "soniox":
if request_type == "transcription":
return litellm.SonioxAudioTranscriptionConfig().get_supported_openai_params(
model=model
)
elif custom_llm_provider in litellm._custom_providers:
if request_type == "chat_completion":
provider_config = litellm.ProviderConfigManager.get_provider_chat_config(

View file

@ -0,0 +1 @@
"""Soniox LLM provider implementation."""

View file

@ -0,0 +1 @@
"""Soniox audio transcription implementation."""

View file

@ -0,0 +1,781 @@
"""
Handler for Soniox async speech-to-text transcription.
Soniox's async transcription API requires multiple HTTP calls:
1. (optional) POST /v1/files — upload a local audio file
2. POST /v1/transcriptions — create a transcription job
3. GET /v1/transcriptions/{id} — poll until status == "completed"
4. GET /v1/transcriptions/{id}/transcript — fetch the transcript
5. (optional) DELETE /v1/transcriptions/{id} — cleanup
6. (optional) DELETE /v1/files/{id} — cleanup
Because this does not fit the single-request shape of
`base_llm_http_handler.audio_transcriptions`, the dispatch in
`litellm.main.transcription()` routes Soniox requests directly to this
handler (analogous to the OpenAI / Azure transcription handlers).
"""
import asyncio
import math
import time
from typing import (
TYPE_CHECKING,
Any,
Coroutine,
Dict,
List,
Optional,
Tuple,
Union,
)
import httpx
from litellm.litellm_core_utils.audio_utils.utils import (
get_audio_file_name,
process_audio_file,
)
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
_get_httpx_client,
get_async_httpx_client,
)
from litellm.llms.soniox.audio_transcription.transformation import (
SonioxAudioTranscriptionConfig,
)
from litellm.llms.soniox.common_utils import (
SONIOX_DEFAULT_CLEANUP,
SONIOX_DEFAULT_MAX_POLL_ATTEMPTS,
SONIOX_DEFAULT_POLL_INTERVAL,
SONIOX_MAX_POLL_ATTEMPTS,
SONIOX_MAX_POLL_INTERVAL,
SONIOX_MIN_POLL_INTERVAL,
SONIOX_SECRET_FIELDS,
SonioxException,
get_soniox_api_base,
)
from litellm.types.utils import FileTypes, TranscriptionResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObj,
)
else:
LiteLLMLoggingObj = Any
class SonioxAudioTranscriptionHandler:
"""Orchestrates the Soniox async transcription flow."""
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
def audio_transcriptions(
self,
model: str,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
model_response: TranscriptionResponse,
timeout: float,
max_retries: int,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
atranscription: bool = False,
headers: Optional[Dict[str, Any]] = None,
provider_config: Optional[SonioxAudioTranscriptionConfig] = None,
) -> Union[TranscriptionResponse, Coroutine[Any, Any, TranscriptionResponse]]:
"""Sync/async dispatch for Soniox transcription requests.
Note: ``max_retries`` is accepted for signature compatibility with
``litellm.transcription`` but is **not yet implemented** for the Soniox
async pipeline. Transient HTTP failures during upload, create, poll,
or fetch will surface immediately. Wrap calls with the standard
``litellm.Router`` / ``num_retries`` mechanism for retry behaviour.
"""
config = provider_config or SonioxAudioTranscriptionConfig()
if atranscription is True:
return self._async_audio_transcriptions(
model=model,
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
model_response=model_response,
timeout=timeout,
logging_obj=logging_obj,
api_key=api_key,
api_base=api_base,
client=client if isinstance(client, AsyncHTTPHandler) else None,
headers=headers or {},
provider_config=config,
)
return self._sync_audio_transcriptions(
model=model,
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
model_response=model_response,
timeout=timeout,
logging_obj=logging_obj,
api_key=api_key,
api_base=api_base,
client=client if isinstance(client, HTTPHandler) else None,
headers=headers or {},
provider_config=config,
)
# ------------------------------------------------------------------
# Helpers shared between sync and async paths
# ------------------------------------------------------------------
def _prepare(
self,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str],
api_base: Optional[str],
provider_config: SonioxAudioTranscriptionConfig,
headers: Dict[str, Any],
) -> Tuple[
Dict[str, str], # auth headers
str, # api_base (no trailing slash)
Dict[str, Any], # body for POST /v1/transcriptions (without file_id/audio_url)
Dict[str, Any], # handler-only options (poll interval, cleanup, ...)
]:
# Validate env -> auth headers.
auth_headers = provider_config.validate_environment(
headers=headers,
model="", # unused
messages=[],
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
)
base_url = get_soniox_api_base(api_base)
# Operate on a local copy so we don't mutate the caller's dict
# (the caller may reuse `optional_params` for retries or logging).
params = dict(optional_params)
# Pull handler-only kwargs out of params so they aren't sent
# to Soniox.
poll_interval = float(
params.pop("soniox_polling_interval", SONIOX_DEFAULT_POLL_INTERVAL)
)
try:
max_attempts = int(
params.pop(
"soniox_max_polling_attempts", SONIOX_DEFAULT_MAX_POLL_ATTEMPTS
)
)
except (ValueError, OverflowError):
max_attempts = SONIOX_DEFAULT_MAX_POLL_ATTEMPTS
cleanup_raw = params.pop("soniox_cleanup", SONIOX_DEFAULT_CLEANUP)
if cleanup_raw is None:
cleanup: List[str] = []
elif isinstance(cleanup_raw, str):
cleanup = [cleanup_raw]
else:
cleanup = list(cleanup_raw)
filename_override = params.pop("filename", None)
# Server-side clamps. Caller-supplied poll settings (from request kwargs)
# are bounded so an authenticated caller cannot force a worker into a
# tight poll loop (zero interval) or pin it indefinitely (huge attempt
# count). Total polling time is bounded by
# SONIOX_MAX_POLL_ATTEMPTS * SONIOX_MAX_POLL_INTERVAL.
if not math.isfinite(poll_interval):
poll_interval = SONIOX_DEFAULT_POLL_INTERVAL
clamped_poll_interval = max(
SONIOX_MIN_POLL_INTERVAL, min(poll_interval, SONIOX_MAX_POLL_INTERVAL)
)
clamped_max_attempts = max(1, min(max_attempts, SONIOX_MAX_POLL_ATTEMPTS))
handler_opts: Dict[str, Any] = {
"poll_interval": clamped_poll_interval,
"max_attempts": clamped_max_attempts,
"cleanup": cleanup,
"filename_override": filename_override,
"audio_url": params.pop("audio_url", None),
"file_id": params.pop("file_id", None),
}
# Soniox does not accept `language` directly; map_openai_params should
# already have translated it, but drop any leftover to be safe.
params.pop("language", None)
# response_format is handled by LiteLLM post-processing, not Soniox.
handler_opts["response_format"] = params.pop("response_format", None)
return auth_headers, base_url, params, handler_opts
def _build_create_body(
self,
model: str,
optional_params: dict,
handler_opts: Dict[str, Any],
file_id: Optional[str],
) -> Dict[str, Any]:
body: Dict[str, Any] = {"model": model}
# Soniox-native passthrough fields
for key, value in optional_params.items():
if value is None:
continue
body[key] = value
if handler_opts.get("audio_url"):
body["audio_url"] = handler_opts["audio_url"]
if file_id:
body["file_id"] = file_id
return body
@staticmethod
def _redact_body_for_logging(body: Dict[str, Any]) -> Dict[str, Any]:
"""Return a shallow copy of ``body`` with secret fields redacted.
Soniox's create-transcription body can include
``webhook_auth_header_value`` (a shared secret used to authenticate
webhook callbacks). Forwarding that value to logging callbacks would
let anyone with read access to those sinks forge webhook requests, so
we replace any value of a known secret-bearing field with the literal
``"[REDACTED]"`` before logging. Non-secret fields are passed through
unchanged.
"""
if not body:
return body
redacted = dict(body)
for field in SONIOX_SECRET_FIELDS:
if field in redacted and redacted[field] is not None:
redacted[field] = "[REDACTED]"
return redacted
@staticmethod
def _safe_log_pre_call(
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: str,
body: Dict[str, Any],
) -> None:
try:
logging_obj.pre_call(
input=None,
api_key=api_key,
additional_args={
"api_base": f"{api_base}/v1/transcriptions",
"atranscription": True,
"complete_input_dict": SonioxAudioTranscriptionHandler._redact_body_for_logging(
body
),
},
)
except Exception:
# Logging hooks are best-effort: a misbehaving callback or third-party
# observability integration must never break a real Soniox call.
pass
@staticmethod
def _safe_log_post_call(
logging_obj: LiteLLMLoggingObj,
audio_file: Optional[FileTypes],
api_key: Optional[str],
body: Dict[str, Any],
original_response: Any,
) -> None:
try:
logging_obj.post_call(
input=get_audio_file_name(audio_file) if audio_file else None,
api_key=api_key,
additional_args={
"complete_input_dict": SonioxAudioTranscriptionHandler._redact_body_for_logging(
body
)
},
original_response=original_response,
)
except Exception:
# Logging hooks are best-effort: a misbehaving callback or third-party
# observability integration must never break a real Soniox call.
pass
@staticmethod
def _raise_for_response(
response: httpx.Response,
provider_config: SonioxAudioTranscriptionConfig,
action: str,
) -> None:
if response.status_code >= 400:
try:
payload = response.json()
message = (
payload.get("error_message")
or payload.get("error")
or response.text
)
except Exception:
message = response.text
raise provider_config.get_error_class(
error_message=f"Soniox {action} failed (HTTP {response.status_code}): {message}",
status_code=response.status_code,
headers=response.headers,
)
# ------------------------------------------------------------------
# Sync flow
# ------------------------------------------------------------------
def _sync_audio_transcriptions(
self,
model: str,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
model_response: TranscriptionResponse,
timeout: float,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
client: Optional[HTTPHandler],
headers: Dict[str, Any],
provider_config: SonioxAudioTranscriptionConfig,
) -> TranscriptionResponse:
auth_headers, base_url, opt_params, handler_opts = self._prepare(
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
provider_config=provider_config,
headers=headers,
)
http_client = (
client
if isinstance(client, HTTPHandler)
else (
_get_httpx_client(
params={"ssl_verify": litellm_params.get("ssl_verify", None)},
)
)
)
file_id = handler_opts.get("file_id")
uploaded_file_id: Optional[str] = None
transcription_id: Optional[str] = None
try:
if not file_id and not handler_opts.get("audio_url"):
if audio_file is None:
raise SonioxException(
message=(
"Soniox transcription requires one of: a file argument, "
"an `audio_url` kwarg, or a `file_id` kwarg."
),
status_code=400,
headers=None,
)
uploaded_file_id = self._sync_upload_file(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
audio_file=audio_file,
filename_override=handler_opts.get("filename_override"),
timeout=timeout,
provider_config=provider_config,
)
file_id = uploaded_file_id
body = self._build_create_body(model, opt_params, handler_opts, file_id)
self._safe_log_pre_call(logging_obj, api_key, base_url, body)
create_resp = http_client.post(
url=f"{base_url}/v1/transcriptions",
headers=auth_headers,
json=body,
timeout=timeout,
)
self._raise_for_response(
create_resp, provider_config, "create transcription"
)
transcription_id = create_resp.json()["id"]
transcription_meta = self._sync_poll_until_completed(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
transcription_id=transcription_id,
poll_interval=handler_opts["poll_interval"],
max_attempts=handler_opts["max_attempts"],
timeout=timeout,
provider_config=provider_config,
)
transcript_resp = http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}/transcript",
headers=auth_headers,
)
self._raise_for_response(
transcript_resp, provider_config, "fetch transcript"
)
transcript = transcript_resp.json()
payload = {"transcription": transcription_meta, "transcript": transcript}
response = provider_config._build_response_from_payload(
payload,
model_response=model_response,
response_format=handler_opts.get("response_format"),
)
self._safe_log_post_call(logging_obj, audio_file, api_key, body, payload)
# Carry through hidden_params hints expected by the rest of litellm.
response._hidden_params.update(
{"model": model, "custom_llm_provider": "soniox"}
)
return response
finally:
self._sync_cleanup(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
cleanup=handler_opts["cleanup"],
file_id_to_cleanup=uploaded_file_id,
transcription_id=transcription_id,
timeout=timeout,
)
def _sync_upload_file(
self,
http_client: HTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
audio_file: FileTypes,
filename_override: Optional[str],
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> str:
processed = process_audio_file(audio_file)
filename = filename_override or processed.filename
files = {
"file": (filename, processed.file_content, processed.content_type),
}
# `Authorization` header is fine; httpx sets multipart Content-Type.
upload_headers = {"Authorization": auth_headers["Authorization"]}
resp = http_client.post(
url=f"{base_url}/v1/files",
headers=upload_headers,
files=files,
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "upload file")
return resp.json()["id"]
def _sync_poll_until_completed(
self,
http_client: HTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
transcription_id: str,
poll_interval: float,
max_attempts: int,
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> Dict[str, Any]:
for _ in range(max_attempts):
resp = http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
)
self._raise_for_response(resp, provider_config, "poll transcription")
data = resp.json()
status = data.get("status")
if status == "completed":
return data
if status == "error":
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} failed: "
f"{data.get('error_message') or data.get('error_type') or 'unknown error'}"
),
status_code=500,
headers=resp.headers,
)
time.sleep(poll_interval)
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} did not complete after "
f"{max_attempts} polling attempts (interval={poll_interval}s)."
),
status_code=504,
headers={},
)
def _sync_cleanup(
self,
http_client: HTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
cleanup: List[str],
file_id_to_cleanup: Optional[str],
transcription_id: Optional[str],
timeout: float,
) -> None:
if not cleanup:
return
if "transcription" in cleanup and transcription_id:
try:
http_client.delete(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort: a failed delete leaves stale data on
# Soniox but must not mask the original transcription result
# (or, on the error path, the original error).
pass
if "file" in cleanup and file_id_to_cleanup:
try:
http_client.delete(
url=f"{base_url}/v1/files/{file_id_to_cleanup}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort; see comment above.
pass
# ------------------------------------------------------------------
# Async flow
# ------------------------------------------------------------------
async def _async_audio_transcriptions(
self,
model: str,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
model_response: TranscriptionResponse,
timeout: float,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
client: Optional[AsyncHTTPHandler],
headers: Dict[str, Any],
provider_config: SonioxAudioTranscriptionConfig,
) -> TranscriptionResponse:
import litellm
auth_headers, base_url, opt_params, handler_opts = self._prepare(
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
provider_config=provider_config,
headers=headers,
)
http_client = (
client
if isinstance(client, AsyncHTTPHandler)
else (
get_async_httpx_client(
llm_provider=litellm.LlmProviders.SONIOX,
params={"ssl_verify": litellm_params.get("ssl_verify", None)},
)
)
)
file_id = handler_opts.get("file_id")
uploaded_file_id: Optional[str] = None
transcription_id: Optional[str] = None
try:
if not file_id and not handler_opts.get("audio_url"):
if audio_file is None:
raise SonioxException(
message=(
"Soniox transcription requires one of: a file argument, "
"an `audio_url` kwarg, or a `file_id` kwarg."
),
status_code=400,
headers=None,
)
uploaded_file_id = await self._async_upload_file(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
audio_file=audio_file,
filename_override=handler_opts.get("filename_override"),
timeout=timeout,
provider_config=provider_config,
)
file_id = uploaded_file_id
body = self._build_create_body(model, opt_params, handler_opts, file_id)
self._safe_log_pre_call(logging_obj, api_key, base_url, body)
create_resp = await http_client.post(
url=f"{base_url}/v1/transcriptions",
headers=auth_headers,
json=body,
timeout=timeout,
)
self._raise_for_response(
create_resp, provider_config, "create transcription"
)
transcription_id = create_resp.json()["id"]
transcription_meta = await self._async_poll_until_completed(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
transcription_id=transcription_id,
poll_interval=handler_opts["poll_interval"],
max_attempts=handler_opts["max_attempts"],
timeout=timeout,
provider_config=provider_config,
)
transcript_resp = await http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}/transcript",
headers=auth_headers,
)
self._raise_for_response(
transcript_resp, provider_config, "fetch transcript"
)
transcript = transcript_resp.json()
payload = {"transcription": transcription_meta, "transcript": transcript}
response = provider_config._build_response_from_payload(
payload,
model_response=model_response,
response_format=handler_opts.get("response_format"),
)
self._safe_log_post_call(logging_obj, audio_file, api_key, body, payload)
response._hidden_params.update(
{"model": model, "custom_llm_provider": "soniox"}
)
return response
finally:
await self._async_cleanup(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
cleanup=handler_opts["cleanup"],
file_id_to_cleanup=uploaded_file_id,
transcription_id=transcription_id,
timeout=timeout,
)
async def _async_upload_file(
self,
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
audio_file: FileTypes,
filename_override: Optional[str],
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> str:
processed = process_audio_file(audio_file)
filename = filename_override or processed.filename
files = {
"file": (filename, processed.file_content, processed.content_type),
}
upload_headers = {"Authorization": auth_headers["Authorization"]}
resp = await http_client.post(
url=f"{base_url}/v1/files",
headers=upload_headers,
files=files,
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "upload file")
return resp.json()["id"]
async def _async_poll_until_completed(
self,
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
transcription_id: str,
poll_interval: float,
max_attempts: int,
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> Dict[str, Any]:
for _ in range(max_attempts):
resp = await http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
)
self._raise_for_response(resp, provider_config, "poll transcription")
data = resp.json()
status = data.get("status")
if status == "completed":
return data
if status == "error":
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} failed: "
f"{data.get('error_message') or data.get('error_type') or 'unknown error'}"
),
status_code=500,
headers=resp.headers,
)
await asyncio.sleep(poll_interval)
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} did not complete after "
f"{max_attempts} polling attempts (interval={poll_interval}s)."
),
status_code=504,
headers={},
)
async def _async_cleanup(
self,
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
cleanup: List[str],
file_id_to_cleanup: Optional[str],
transcription_id: Optional[str],
timeout: float,
) -> None:
if not cleanup:
return
if "transcription" in cleanup and transcription_id:
try:
await http_client.delete(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort: a failed delete leaves stale data on
# Soniox but must not mask the original transcription result
# (or, on the error path, the original error).
pass
if "file" in cleanup and file_id_to_cleanup:
try:
await http_client.delete(
url=f"{base_url}/v1/files/{file_id_to_cleanup}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort; see comment above.
pass

View file

@ -0,0 +1,281 @@
"""
Translates between OpenAI's `/v1/audio/transcriptions` shape and Soniox's
async transcription API (https://soniox.com/docs/stt/async/async-transcription).
This config covers parameter mapping, env validation and response shaping.
The actual orchestration (file upload -> create -> poll -> fetch -> cleanup)
lives in `litellm.llms.soniox.audio_transcription.handler`, because Soniox's
async API requires multiple HTTP calls and does not fit the single-request
contract of `base_llm_http_handler.audio_transcriptions`.
"""
from typing import Any, Dict, List, Optional, Union
from httpx import Headers, Response
from litellm.llms.base_llm.audio_transcription.transformation import (
AudioTranscriptionRequestData,
BaseAudioTranscriptionConfig,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.soniox.common_utils import (
SonioxException,
get_soniox_api_base,
get_soniox_api_key,
render_soniox_tokens,
render_soniox_tokens_as_srt,
render_soniox_tokens_as_vtt,
)
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIAudioTranscriptionOptionalParams,
)
from litellm.types.utils import FileTypes, TranscriptionResponse
# Soniox-native kwargs the user can pass through `litellm.transcription(..., **kwargs)`
# in addition to the standard OpenAI params.
SONIOX_PASSTHROUGH_PARAMS: List[str] = [
"language_hints",
"language_hints_strict",
"enable_language_identification",
"enable_speaker_diarization",
"context",
"translation",
"client_reference_id",
"webhook_url",
"webhook_auth_header_name",
"webhook_auth_header_value",
"audio_url",
"file_id",
]
# Handler-only kwargs (consumed by the handler, not sent to Soniox).
SONIOX_HANDLER_ONLY_PARAMS: List[str] = [
"soniox_polling_interval",
"soniox_max_polling_attempts",
"soniox_cleanup",
"filename",
]
class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
"""Configuration for Soniox async speech-to-text transcription."""
def get_supported_openai_params(
self, model: str
) -> List[OpenAIAudioTranscriptionOptionalParams]:
# `language` is mapped onto Soniox's `language_hints`.
# `response_format` is handled by LiteLLM (Soniox doesn't support
# SRT/VTT natively but we synthesize them from token timestamps).
return ["language", "response_format"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
# Translate the OpenAI `language` param into Soniox `language_hints`.
if "language" in non_default_params and non_default_params["language"]:
language = non_default_params["language"]
existing_hints = optional_params.get("language_hints")
if not existing_hints:
optional_params["language_hints"] = [language]
elif language not in existing_hints:
optional_params["language_hints"] = [language] + list(existing_hints)
# Capture response_format for post-processing (not sent to Soniox API).
if "response_format" in non_default_params:
optional_params["response_format"] = non_default_params["response_format"]
# Pass through Soniox-native kwargs unchanged.
for key in SONIOX_PASSTHROUGH_PARAMS + SONIOX_HANDLER_ONLY_PARAMS:
if key in non_default_params and non_default_params[key] is not None:
optional_params[key] = non_default_params[key]
return optional_params
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, Headers]
) -> BaseLLMException:
return SonioxException(
message=error_message, status_code=status_code, headers=headers
)
def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
resolved_key = get_soniox_api_key(api_key)
if not resolved_key:
raise SonioxException(
message=(
"Missing Soniox API key. Set the SONIOX_API_KEY environment "
"variable or pass api_key=... to litellm.transcription()."
),
status_code=401,
headers=None,
)
merged_headers: Dict[str, str] = {
"Authorization": f"Bearer {resolved_key}",
}
if headers:
merged_headers.update(headers)
return merged_headers
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
# The handler builds per-call URLs (uploads, create, poll, fetch, delete);
# we just return the resolved base.
return get_soniox_api_base(api_base)
def transform_audio_transcription_request(
self,
model: str,
audio_file: FileTypes,
optional_params: dict,
litellm_params: dict,
) -> AudioTranscriptionRequestData:
"""
Build the JSON body for `POST /v1/transcriptions`.
The handler is responsible for the file upload (if `audio_file` is bytes)
and for filling in `file_id`/`audio_url`. This method exists so the
config can be exercised in isolation by unit tests.
"""
body: Dict[str, Any] = {"model": model}
for key in SONIOX_PASSTHROUGH_PARAMS:
value = optional_params.get(key)
if value is not None:
body[key] = value
return AudioTranscriptionRequestData(
data=body, files=None, content_type="application/json"
)
def transform_audio_transcription_response(
self,
raw_response: Response,
model_response: Optional[TranscriptionResponse] = None,
) -> TranscriptionResponse:
"""
Build a TranscriptionResponse from a Soniox transcript payload.
`raw_response.json()` may be either:
- a Soniox transcript object: `{"id": "...", "text": "...", "tokens": [...]}`
- or a merged envelope: `{"transcription": {...}, "transcript": {...}}`
produced by the handler so transcription metadata is also available.
"""
try:
payload = raw_response.json()
except Exception as exc:
raise SonioxException(
message=f"Failed to parse Soniox response: {exc}",
status_code=getattr(raw_response, "status_code", 500),
headers=getattr(raw_response, "headers", None),
)
return self._build_response_from_payload(payload, model_response=model_response)
def _build_response_from_payload(
self,
payload: Dict[str, Any],
model_response: Optional[TranscriptionResponse] = None,
response_format: Optional[str] = None,
) -> TranscriptionResponse:
"""Shared response-building logic (also used by the handler)."""
transcription_meta: Dict[str, Any] = {}
transcript: Dict[str, Any]
if isinstance(payload, dict) and "transcript" in payload:
transcription_meta = payload.get("transcription") or {}
transcript = payload.get("transcript") or {}
else:
transcript = payload if isinstance(payload, dict) else {}
tokens: List[Dict[str, Any]] = transcript.get("tokens") or []
# Decide what to put in `text` based on response_format:
# - "srt": render tokens as SRT subtitles (synthesized from timestamps)
# - "vtt": render tokens as WebVTT subtitles (synthesized from timestamps)
# - "verbose_json": return JSON with word-level timing (handled below)
# - "text" / "json" / None: default plain text rendering
if response_format == "srt" and tokens:
text = render_soniox_tokens_as_srt(tokens)
elif response_format == "vtt" and tokens:
text = render_soniox_tokens_as_vtt(tokens)
else:
# Default text rendering (also used for "json", "text",
# "verbose_json")
has_speaker = any(t.get("speaker") is not None for t in tokens)
has_language = any(t.get("language") is not None for t in tokens)
if (has_speaker or has_language) and tokens:
text = render_soniox_tokens(tokens)
elif transcript.get("text"):
text = transcript["text"]
elif tokens:
text = "".join(t.get("text", "") for t in tokens)
else:
text = ""
response = model_response or TranscriptionResponse(text=text)
response.text = text
response["task"] = "transcribe"
# Best-effort metadata fields matching OpenAI's verbose_json shape.
if transcription_meta.get("audio_duration_ms") is not None:
try:
response["duration"] = (
float(transcription_meta["audio_duration_ms"]) / 1000.0
)
except (TypeError, ValueError):
pass
# Surface a representative language if all tokens agree.
has_language = any(t.get("language") is not None for t in tokens)
if has_language:
languages = {t.get("language") for t in tokens if t.get("language")}
if len(languages) == 1:
response["language"] = next(iter(languages))
# For verbose_json, include word-level timing from tokens.
if response_format == "verbose_json" and tokens:
words: List[Dict[str, Any]] = []
for token in tokens:
word_entry: Dict[str, Any] = {"word": token.get("text", "")}
if token.get("start_ms") is not None:
word_entry["start"] = float(token["start_ms"]) / 1000.0
if token.get("end_ms") is not None:
word_entry["end"] = float(token["end_ms"]) / 1000.0
words.append(word_entry)
if words:
response["words"] = words
# Stash the raw Soniox payload so power-users can read tokens, segments,
# speaker/language data, etc.
response._hidden_params.update(
{
"soniox_raw": {
"transcription": transcription_meta,
"transcript": transcript,
}
}
)
return response

View file

@ -0,0 +1,274 @@
"""
Shared utilities for the Soniox provider (https://soniox.com).
"""
from typing import Any, Dict, List, Optional
from litellm.llms.base_llm.chat.transformation import BaseLLMException
# Soniox API base URL.
SONIOX_API_BASE: str = "https://api.soniox.com"
# Default polling interval in seconds when waiting for an async transcription
# to finish. Mirrors the Soniox SDK default.
SONIOX_DEFAULT_POLL_INTERVAL: float = 1.0
# Minimum polling interval (in seconds) the server will accept from caller-
# supplied `soniox_polling_interval` kwargs. Prevents an authenticated caller
# from forcing a worker into a tight poll loop with a zero/near-zero interval.
SONIOX_MIN_POLL_INTERVAL: float = 0.5
# Maximum polling interval (in seconds). Prevents a caller from setting an
# excessively large or non-finite interval that would keep a worker sleeping
# far longer than necessary between status checks.
SONIOX_MAX_POLL_INTERVAL: float = 60.0
# Default maximum number of polling attempts (1800 attempts * 1s ~= 30 minutes).
SONIOX_DEFAULT_MAX_POLL_ATTEMPTS: int = 1800
# Hard upper bound on polling attempts. Combined with `SONIOX_MIN_POLL_INTERVAL`
# this caps total polling time per request at ~3000s (50 minutes), preventing a
# caller from pinning a worker indefinitely via a huge attempt count.
SONIOX_MAX_POLL_ATTEMPTS: int = 6000
# Default cleanup behaviour: delete both the uploaded file (if any) and the
# transcription record after the transcript has been fetched.
SONIOX_DEFAULT_CLEANUP: List[str] = ["file", "transcription"]
# Body fields that may carry secrets and must be redacted before being
# forwarded to logging callbacks. Soniox accepts a webhook auth header value
# alongside the create-transcription request; that value lets the recipient
# authenticate webhook callbacks and must not leak into observability sinks.
SONIOX_SECRET_FIELDS: List[str] = ["webhook_auth_header_value"]
class SonioxException(BaseLLMException):
"""Provider-specific exception class for Soniox."""
pass
def get_soniox_api_key(api_key: Optional[str] = None) -> Optional[str]:
"""Resolve the Soniox API key from arg or env var."""
# Local import to avoid a circular import: litellm.secret_managers.main
# imports from litellm at top-level.
from litellm.secret_managers.main import get_secret_str
return api_key or get_secret_str("SONIOX_API_KEY")
def get_soniox_api_base(api_base: Optional[str] = None) -> str:
"""Resolve the Soniox API base URL (defaults to public API)."""
from litellm.secret_managers.main import get_secret_str
base = api_base or get_secret_str("SONIOX_API_BASE") or SONIOX_API_BASE
return base.rstrip("/")
def render_soniox_tokens(tokens: List[Dict[str, Any]]) -> str:
"""
Render a list of Soniox tokens to a readable transcript string.
Mirrors the behaviour of the official Soniox SDK's `renderTokens` helper:
- When the speaker changes, a `Speaker N:` tag is inserted.
- When the language changes, a `[lang]` (or `[Translation][lang]`) tag is
inserted.
If neither speaker nor language information is present on any token (i.e.
diarization and language identification are disabled), the function simply
concatenates the token texts.
"""
if not tokens:
return ""
text_parts: List[str] = []
current_speaker: Optional[Any] = None
current_language: Optional[Any] = None
for token in tokens:
text = token.get("text", "")
speaker = token.get("speaker")
language = token.get("language")
is_translation = token.get("translation_status") == "translation"
# Speaker changed -> emit a speaker tag.
if speaker is not None and speaker != current_speaker:
if current_speaker is not None:
text_parts.append("\n\n")
current_speaker = speaker
current_language = None # reset language whenever speaker changes
text_parts.append(f"Speaker {current_speaker}:")
# Language changed -> emit a language (or translation) tag.
if language is not None and language != current_language:
current_language = language
prefix = "[Translation] " if is_translation else ""
text_parts.append(f"\n{prefix}[{current_language}] ")
text = text.lstrip() if isinstance(text, str) else text
text_parts.append(text)
return "".join(text_parts)
# ---------------------------------------------------------------------------
# SRT / VTT subtitle rendering
# ---------------------------------------------------------------------------
# Maximum number of tokens to group into a single subtitle cue.
_CUE_MAX_TOKENS: int = 15
# Maximum duration (in ms) for a single cue before forcing a break.
_CUE_MAX_DURATION_MS: int = 5000
def _format_timestamp_srt(ms: int) -> str:
"""Format milliseconds as SRT timestamp: HH:MM:SS,mmm"""
if ms < 0:
ms = 0
hours = ms // 3_600_000
ms %= 3_600_000
minutes = ms // 60_000
ms %= 60_000
seconds = ms // 1_000
millis = ms % 1_000
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
def _format_timestamp_vtt(ms: int) -> str:
"""Format milliseconds as VTT timestamp: HH:MM:SS.mmm"""
if ms < 0:
ms = 0
hours = ms // 3_600_000
ms %= 3_600_000
minutes = ms // 60_000
ms %= 60_000
seconds = ms // 1_000
millis = ms % 1_000
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
def _group_tokens_into_cues(
tokens: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Group Soniox tokens into subtitle cues.
Each cue has:
- start_ms: int
- end_ms: int
- text: str
Grouping heuristics:
- A new cue starts when token count exceeds _CUE_MAX_TOKENS.
- A new cue starts when duration exceeds _CUE_MAX_DURATION_MS.
- A new cue starts when the speaker changes (if diarization is on).
- Tokens without timestamps are appended to the current cue.
"""
cues: List[Dict[str, Any]] = []
current_tokens: List[str] = []
current_start: Optional[int] = None
current_end: Optional[int] = None
current_speaker: Optional[Any] = None
def _flush() -> None:
if current_tokens and current_start is not None:
text = "".join(current_tokens).strip()
if text:
cues.append(
{
"start_ms": current_start,
"end_ms": (
current_end if current_end is not None else current_start
),
"text": text,
}
)
for token in tokens:
start_ms = token.get("start_ms")
end_ms = token.get("end_ms")
text = token.get("text", "")
speaker = token.get("speaker")
# Skip tokens with no timestamp data entirely if we have no cue started
if start_ms is None and current_start is None:
continue
# Speaker change forces a new cue
if speaker is not None and speaker != current_speaker:
_flush()
current_tokens = []
current_start = start_ms
current_end = end_ms
current_speaker = speaker
current_tokens.append(text)
continue
# Duration or token count exceeded -> flush
should_break = False
if len(current_tokens) >= _CUE_MAX_TOKENS:
should_break = True
elif (
current_start is not None
and start_ms is not None
and (start_ms - current_start) >= _CUE_MAX_DURATION_MS
):
should_break = True
if should_break:
_flush()
current_tokens = []
current_start = start_ms
current_end = end_ms
current_tokens.append(text)
else:
if current_start is None:
current_start = start_ms
if end_ms is not None:
current_end = end_ms
current_tokens.append(text)
_flush()
return cues
def render_soniox_tokens_as_srt(tokens: List[Dict[str, Any]]) -> str:
"""
Render Soniox tokens as SRT (SubRip) subtitle format.
Returns an empty string if no tokens have timestamp data.
"""
cues = _group_tokens_into_cues(tokens)
if not cues:
return ""
lines: List[str] = []
for idx, cue in enumerate(cues, start=1):
start = _format_timestamp_srt(cue["start_ms"])
end = _format_timestamp_srt(cue["end_ms"])
lines.append(str(idx))
lines.append(f"{start} --> {end}")
lines.append(cue["text"])
lines.append("") # blank line between cues
return "\n".join(lines)
def render_soniox_tokens_as_vtt(tokens: List[Dict[str, Any]]) -> str:
"""
Render Soniox tokens as WebVTT subtitle format.
Returns the VTT header even if no cues are present.
"""
cues = _group_tokens_into_cues(tokens)
lines: List[str] = ["WEBVTT", ""]
for cue in cues:
start = _format_timestamp_vtt(cue["start_ms"])
end = _format_timestamp_vtt(cue["end_ms"])
lines.append(f"{start} --> {end}")
lines.append(cue["text"])
lines.append("") # blank line between cues
return "\n".join(lines)

View file

@ -6655,7 +6655,7 @@ async def atranscription(*args, **kwargs) -> TranscriptionResponse:
@client
def transcription(
def transcription( # noqa: PLR0915
model: str,
file: FileTypes,
## OPTIONAL OPENAI PARAMS ##
@ -6847,6 +6847,35 @@ def transcription(
else None
),
)
elif custom_llm_provider == "soniox":
from litellm.llms.soniox.audio_transcription.handler import (
SonioxAudioTranscriptionHandler,
)
response = SonioxAudioTranscriptionHandler().audio_transcriptions(
model=model,
audio_file=file,
optional_params=optional_params,
litellm_params=litellm_params_dict,
model_response=model_response,
atranscription=atranscription,
client=(
client
if client is not None
and (
isinstance(client, HTTPHandler)
or isinstance(client, AsyncHTTPHandler)
)
else None
),
timeout=timeout,
max_retries=max_retries,
logging_obj=litellm_logging_obj,
api_base=api_base,
api_key=api_key,
headers=extra_headers,
provider_config=provider_config, # type: ignore[arg-type]
)
elif provider_config is not None:
response = base_llm_http_handler.audio_transcriptions(
model=model,

View file

@ -41601,5 +41601,18 @@
"supports_vision": true,
"supports_native_structured_output": true,
"supports_pdf_input": true
},
"soniox/stt-async-v4": {
"litellm_provider": "soniox",
"max_output_tokens": 8000,
"max_tokens": 8000,
"input_cost_per_token": 0.0000035,
"output_cost_per_token": 0.0000035,
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/audio/transcriptions"
],
"supports_audio_input": true
}
}
}

View file

@ -2570,6 +2570,24 @@
],
"default_model_placeholder": "snowflake/mistral-7b"
},
{
"provider": "Soniox",
"provider_display_name": "Soniox",
"litellm_provider": "soniox",
"credential_fields": [
{
"key": "api_key",
"label": "Soniox API Key",
"placeholder": null,
"tooltip": "Currently only the async Speech-to-Text REST API (api.soniox.com) is supported. Realtime STT (stt-rt.soniox.com) and TTS (tts-rt.soniox.com) are not yet available.",
"required": true,
"field_type": "password",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "soniox/stt-async-v4"
},
{
"provider": "TEXT_COMPLETION_CODESTRAL",
"provider_display_name": "Text-Completion-Codestral",

View file

@ -3290,6 +3290,7 @@ class LlmProviders(str, Enum):
GIGACHAT = "gigachat"
NVIDIA_NIM = "nvidia_nim"
NVIDIA_RIVA = "nvidia_riva"
SONIOX = "soniox"
CEREBRAS = "cerebras"
AI21_CHAT = "ai21_chat"
VOLCENGINE = "volcengine"

View file

@ -8816,6 +8816,12 @@ class ProviderConfigManager:
)
return NvidiaRivaAudioTranscriptionConfig()
elif litellm.LlmProviders.SONIOX == provider:
from litellm.llms.soniox.audio_transcription.transformation import (
SonioxAudioTranscriptionConfig,
)
return SonioxAudioTranscriptionConfig()
return None
@staticmethod

View file

@ -41810,6 +41810,16 @@
"output_cost_per_token": 0.0,
"litellm_provider": "snowflake",
"mode": "embedding"
},
"soniox/stt-async-v4": {
"litellm_provider": "soniox",
"max_output_tokens": 8000,
"max_tokens": 8000,
"input_cost_per_token": 0.0000035,
"output_cost_per_token": 0.0000035,
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": ["/v1/audio/transcriptions"],
"supports_audio_input": true
}
}

View file

@ -2081,6 +2081,22 @@
"interactions": true
}
},
"soniox": {
"display_name": "Soniox (`soniox`)",
"url": "https://docs.litellm.ai/docs/providers/soniox",
"endpoints": {
"chat_completions": false,
"messages": false,
"responses": false,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": true,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false
}
},
"synthetic": {
"display_name": "Synthetic (`synthetic`)",
"endpoints": {

View file

@ -0,0 +1 @@
"""Soniox provider tests."""

View file

@ -0,0 +1 @@
"""Soniox audio transcription tests."""

View file

@ -0,0 +1,971 @@
"""Tests for SonioxAudioTranscriptionHandler."""
import asyncio
import json
from typing import Any, Dict, List
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.soniox.audio_transcription.handler import (
SonioxAudioTranscriptionHandler,
)
from litellm.llms.soniox.audio_transcription.transformation import (
SonioxAudioTranscriptionConfig,
)
from litellm.llms.soniox.common_utils import SonioxException
from litellm.types.utils import TranscriptionResponse
def _make_response(payload: Dict[str, Any], status_code: int = 200) -> httpx.Response:
return httpx.Response(
status_code=status_code,
content=json.dumps(payload).encode("utf-8"),
headers={"content-type": "application/json"},
)
class _MockSyncClient(HTTPHandler):
"""Sync HTTP client that records calls and replays scripted responses."""
def __init__(self, responses: Dict[str, List[httpx.Response]]):
# Skip parent __init__ (don't open real httpx client).
self._responses = responses
self.calls: List[Dict[str, Any]] = []
def _next(self, method: str, url: str) -> httpx.Response:
key = f"{method.upper()} {url}"
bucket = self._responses.get(key)
if not bucket:
raise AssertionError(f"Unexpected call: {key}")
return bucket.pop(0)
def post(self, url, headers=None, json=None, files=None, data=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "POST", "url": url, "json": json, "files": files})
return self._next("POST", url)
def get(self, url, headers=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "GET", "url": url})
return self._next("GET", url)
def delete(self, url, headers=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "DELETE", "url": url})
return self._next("DELETE", url)
class _MockAsyncClient(AsyncHTTPHandler):
def __init__(self, responses: Dict[str, List[httpx.Response]]):
self._responses = responses
self.calls: List[Dict[str, Any]] = []
def _next(self, method: str, url: str) -> httpx.Response:
key = f"{method.upper()} {url}"
bucket = self._responses.get(key)
if not bucket:
raise AssertionError(f"Unexpected call: {key}")
return bucket.pop(0)
async def post(self, url, headers=None, json=None, files=None, data=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "POST", "url": url, "json": json, "files": files})
return self._next("POST", url)
async def get(self, url, headers=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "GET", "url": url})
return self._next("GET", url)
async def delete(self, url, headers=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "DELETE", "url": url})
return self._next("DELETE", url)
def _make_logging_obj() -> MagicMock:
obj = MagicMock()
obj.pre_call = MagicMock()
obj.post_call = MagicMock()
return obj
def _common_call_kwargs(client) -> Dict[str, Any]:
return {
"model": "stt-async-v4",
"model_response": TranscriptionResponse(),
"timeout": 30.0,
"max_retries": 0,
"logging_obj": _make_logging_obj(),
"api_key": "sk-test",
"api_base": None,
"client": client,
"headers": {},
}
class TestSyncAudioUrl:
def test_should_create_poll_fetch_and_cleanup_when_audio_url_supplied(
self, monkeypatch
):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1", "status": "queued"})
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response(
{"id": "tx_1", "status": "completed", "audio_duration_ms": 1500}
),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "hello world", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"deleted": True}),
],
}
client = _MockSyncClient(responses)
handler = SonioxAudioTranscriptionHandler()
resp = handler.audio_transcriptions(
audio_file=None,
optional_params={"audio_url": "https://example.com/a.wav"},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert resp.text == "hello world"
assert resp["duration"] == pytest.approx(1.5)
assert resp._hidden_params["custom_llm_provider"] == "soniox"
# POST body should contain audio_url, no file_id.
post_call = next(c for c in client.calls if c["method"] == "POST")
assert post_call["json"]["audio_url"] == "https://example.com/a.wav"
assert "file_id" not in post_call["json"]
# Cleanup must have deleted the transcription record.
assert any(c["method"] == "DELETE" for c in client.calls)
class TestSyncFileUpload:
def test_should_upload_then_transcribe_then_cleanup_both(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/files": [
_make_response({"id": "file_1"}),
],
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "uploaded ok", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({}),
],
"DELETE https://api.soniox.com/v1/files/file_1": [
_make_response({}),
],
}
client = _MockSyncClient(responses)
handler = SonioxAudioTranscriptionHandler()
resp = handler.audio_transcriptions(
audio_file=("clip.wav", b"RIFFfake", "audio/wav"),
optional_params={},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert resp.text == "uploaded ok"
deletes = [c["url"] for c in client.calls if c["method"] == "DELETE"]
assert "https://api.soniox.com/v1/transcriptions/tx_1" in deletes
assert "https://api.soniox.com/v1/files/file_1" in deletes
class TestSyncPolling:
def test_should_poll_until_status_is_completed(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "queued"}),
_make_response({"status": "processing"}),
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "done", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({}),
],
}
client = _MockSyncClient(responses)
resp = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_polling_interval": 0,
},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert resp.text == "done"
def test_should_raise_when_status_is_error(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "error", "error_message": "bad audio"}),
],
}
client = _MockSyncClient(responses)
with pytest.raises(SonioxException) as exc_info:
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={"audio_url": "https://example.com/a.wav"},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert "bad audio" in str(exc_info.value)
def test_should_raise_when_polling_attempts_exceeded(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "processing"}),
_make_response({"status": "processing"}),
],
}
client = _MockSyncClient(responses)
with pytest.raises(SonioxException) as exc_info:
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_polling_interval": 0,
"soniox_max_polling_attempts": 2,
},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert exc_info.value.status_code == 504
class TestPollLimitsClamping:
"""Server-side caps on caller-supplied poll settings.
`soniox_polling_interval` and `soniox_max_polling_attempts` arrive as
request kwargs from authenticated callers. They MUST be clamped server-side
so a hostile caller cannot set a zero interval + huge attempt count to pin
a worker on tight poll loops.
"""
def test_should_clamp_poll_interval_to_minimum(self):
from litellm.llms.soniox.common_utils import SONIOX_MIN_POLL_INTERVAL
handler = SonioxAudioTranscriptionHandler()
_, _, _, handler_opts = handler._prepare(
audio_file=None,
optional_params={
"soniox_polling_interval": 0,
"audio_url": "https://example.com/a.wav",
},
litellm_params={},
api_key="sk-test",
api_base=None,
provider_config=SonioxAudioTranscriptionConfig(),
headers={},
)
assert handler_opts["poll_interval"] == SONIOX_MIN_POLL_INTERVAL
def test_should_clamp_negative_poll_interval_to_minimum(self):
from litellm.llms.soniox.common_utils import SONIOX_MIN_POLL_INTERVAL
handler = SonioxAudioTranscriptionHandler()
_, _, _, handler_opts = handler._prepare(
audio_file=None,
optional_params={
"soniox_polling_interval": -10,
"audio_url": "https://example.com/a.wav",
},
litellm_params={},
api_key="sk-test",
api_base=None,
provider_config=SonioxAudioTranscriptionConfig(),
headers={},
)
assert handler_opts["poll_interval"] == SONIOX_MIN_POLL_INTERVAL
def test_should_preserve_poll_interval_when_above_minimum(self):
handler = SonioxAudioTranscriptionHandler()
_, _, _, handler_opts = handler._prepare(
audio_file=None,
optional_params={
"soniox_polling_interval": 5.0,
"audio_url": "https://example.com/a.wav",
},
litellm_params={},
api_key="sk-test",
api_base=None,
provider_config=SonioxAudioTranscriptionConfig(),
headers={},
)
assert handler_opts["poll_interval"] == 5.0
def test_should_clamp_max_attempts_to_upper_bound(self):
from litellm.llms.soniox.common_utils import SONIOX_MAX_POLL_ATTEMPTS
handler = SonioxAudioTranscriptionHandler()
_, _, _, handler_opts = handler._prepare(
audio_file=None,
optional_params={
"soniox_max_polling_attempts": 10**9,
"audio_url": "https://example.com/a.wav",
},
litellm_params={},
api_key="sk-test",
api_base=None,
provider_config=SonioxAudioTranscriptionConfig(),
headers={},
)
assert handler_opts["max_attempts"] == SONIOX_MAX_POLL_ATTEMPTS
def test_should_clamp_zero_max_attempts_to_one(self):
handler = SonioxAudioTranscriptionHandler()
_, _, _, handler_opts = handler._prepare(
audio_file=None,
optional_params={
"soniox_max_polling_attempts": 0,
"audio_url": "https://example.com/a.wav",
},
litellm_params={},
api_key="sk-test",
api_base=None,
provider_config=SonioxAudioTranscriptionConfig(),
headers={},
)
assert handler_opts["max_attempts"] == 1
def test_should_preserve_max_attempts_within_bounds(self):
handler = SonioxAudioTranscriptionHandler()
_, _, _, handler_opts = handler._prepare(
audio_file=None,
optional_params={
"soniox_max_polling_attempts": 10,
"audio_url": "https://example.com/a.wav",
},
litellm_params={},
api_key="sk-test",
api_base=None,
provider_config=SonioxAudioTranscriptionConfig(),
headers={},
)
assert handler_opts["max_attempts"] == 10
class TestSyncCleanupBehavior:
def test_should_skip_cleanup_when_disabled(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "no cleanup", "tokens": []}),
],
}
client = _MockSyncClient(responses)
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_cleanup": [],
},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert not any(c["method"] == "DELETE" for c in client.calls)
def test_should_cleanup_even_on_error(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/files": [
_make_response({"id": "file_99"}),
],
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_99"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_99": [
_make_response({"status": "error", "error_message": "boom"}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_99": [
_make_response({}),
],
"DELETE https://api.soniox.com/v1/files/file_99": [
_make_response({}),
],
}
client = _MockSyncClient(responses)
with pytest.raises(SonioxException):
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=("clip.wav", b"x", "audio/wav"),
optional_params={},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
deletes = [c["url"] for c in client.calls if c["method"] == "DELETE"]
assert any("/v1/files/file_99" in u for u in deletes)
class TestLoggingExceptionSafety:
"""Logging callbacks must never break a real Soniox call.
`_safe_log_pre_call` and `_safe_log_post_call` wrap their `logging_obj`
invocations in a broad `except Exception: pass` because callbacks come
from third-party observability integrations and a misbehaving one must
not abort the transcription.
"""
def test_pre_call_should_swallow_logging_exception(self):
logging_obj = MagicMock()
logging_obj.pre_call.side_effect = RuntimeError("callback boom")
# Must not raise.
SonioxAudioTranscriptionHandler._safe_log_pre_call(
logging_obj=logging_obj,
api_key="sk-test",
api_base="https://api.soniox.com",
body={"model": "stt-async-v4"},
)
# Helper still attempted the call exactly once before swallowing.
assert logging_obj.pre_call.call_count == 1
def test_post_call_should_swallow_logging_exception(self):
logging_obj = MagicMock()
logging_obj.post_call.side_effect = RuntimeError("callback boom")
# Must not raise.
SonioxAudioTranscriptionHandler._safe_log_post_call(
logging_obj=logging_obj,
audio_file=None,
api_key="sk-test",
body={"model": "stt-async-v4"},
original_response={"transcription": {}, "transcript": {}},
)
assert logging_obj.post_call.call_count == 1
class _RaisingDeleteSyncClient(_MockSyncClient):
"""Sync mock whose DELETE calls always raise.
Used to drive the `_sync_cleanup` exception-swallowing branches: a failed
DELETE during cleanup must not mask the transcription result (or the
original error on the failure path).
"""
def delete(self, url, headers=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "DELETE", "url": url})
raise httpx.ConnectError("delete failed")
class _RaisingDeleteAsyncClient(_MockAsyncClient):
"""Async counterpart of `_RaisingDeleteSyncClient`."""
async def delete(self, url, headers=None, timeout=None, **kw): # type: ignore[override]
self.calls.append({"method": "DELETE", "url": url})
raise httpx.ConnectError("delete failed")
class TestCleanupExceptionMasking:
"""Cleanup DELETE failures must be swallowed (best-effort).
A failed DELETE leaves stale data on Soniox but must NOT replace the
successful transcription result, nor mask the original error on the
error path.
"""
def test_sync_cleanup_should_swallow_delete_failures(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/files": [
_make_response({"id": "file_99"}),
],
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_99"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_99": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_99/transcript": [
_make_response({"text": "ok", "tokens": []}),
],
}
client = _RaisingDeleteSyncClient(responses)
# Result must come through despite both DELETEs raising.
resp = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=("clip.wav", b"x", "audio/wav"),
optional_params={"soniox_cleanup": ["file", "transcription"]},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert resp.text == "ok"
# Both DELETEs were attempted (proving the except: pass paths ran).
deletes = [c["url"] for c in client.calls if c["method"] == "DELETE"]
assert any("/v1/transcriptions/tx_99" in u for u in deletes)
assert any("/v1/files/file_99" in u for u in deletes)
def test_async_cleanup_should_swallow_delete_failures(self, monkeypatch):
async def _no_sleep(*_args, **_kwargs):
return None
monkeypatch.setattr("asyncio.sleep", _no_sleep)
responses = {
"POST https://api.soniox.com/v1/files": [
_make_response({"id": "file_async"}),
],
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_async"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_async": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_async/transcript": [
_make_response({"text": "async ok", "tokens": []}),
],
}
client = _RaisingDeleteAsyncClient(responses)
coro = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=("clip.wav", b"x", "audio/wav"),
optional_params={"soniox_cleanup": ["file", "transcription"]},
litellm_params={},
atranscription=True,
**_common_call_kwargs(client),
)
resp = asyncio.new_event_loop().run_until_complete(coro)
assert resp.text == "async ok"
deletes = [c["url"] for c in client.calls if c["method"] == "DELETE"]
assert any("/v1/transcriptions/tx_async" in u for u in deletes)
assert any("/v1/files/file_async" in u for u in deletes)
class TestMissingInput:
def test_should_raise_when_no_audio_input_provided(self):
client = _MockSyncClient({})
with pytest.raises(SonioxException) as exc_info:
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert exc_info.value.status_code == 400
class TestCleanupNormalization:
def test_should_treat_none_cleanup_as_no_cleanup(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "hi", "tokens": []}),
],
}
client = _MockSyncClient(responses)
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_cleanup": None,
},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert not any(c["method"] == "DELETE" for c in client.calls)
def test_should_accept_cleanup_as_single_string(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "hi", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({}),
],
}
client = _MockSyncClient(responses)
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_cleanup": "transcription",
},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
deletes = [c["url"] for c in client.calls if c["method"] == "DELETE"]
assert "https://api.soniox.com/v1/transcriptions/tx_1" in deletes
class TestErrorResponses:
def test_should_raise_on_4xx_during_create_with_json_error(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"error_message": "invalid model"}, status_code=400),
],
}
client = _MockSyncClient(responses)
with pytest.raises(SonioxException) as exc_info:
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={"audio_url": "https://example.com/a.wav"},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert "invalid model" in str(exc_info.value)
assert exc_info.value.status_code == 400
def test_should_raise_on_4xx_during_create_with_non_json_body(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
httpx.Response(status_code=500, content=b"server exploded"),
],
}
client = _MockSyncClient(responses)
with pytest.raises(SonioxException) as exc_info:
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={"audio_url": "https://example.com/a.wav"},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
assert "server exploded" in str(exc_info.value)
assert exc_info.value.status_code == 500
class TestPassthroughBodyBuilding:
def test_should_skip_none_values_in_passthrough_body(self, monkeypatch):
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "ok", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({}),
],
}
client = _MockSyncClient(responses)
# Pass a None-valued kwarg through the entire pipeline (it must not
# appear in the create body).
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"context": None,
},
litellm_params={},
atranscription=False,
**_common_call_kwargs(client),
)
post_call = next(c for c in client.calls if c["method"] == "POST")
assert "context" not in post_call["json"]
class TestSecretRedaction:
"""Secret-bearing fields must be redacted before reaching logging callbacks.
`webhook_auth_header_value` is forwarded to Soniox so it can authenticate
its webhook callbacks to the caller. It must NOT leak into LiteLLM logging
callbacks: anyone with access to those sinks could otherwise forge webhook
requests. The HTTP request to Soniox itself must still carry the real
value.
"""
def test_redact_helper_should_redact_known_secret_fields(self):
body = {
"model": "stt-async-v4",
"audio_url": "https://example.com/a.wav",
"webhook_url": "https://example.com/hook",
"webhook_auth_header_name": "X-Webhook-Auth",
"webhook_auth_header_value": "super-secret-token",
}
redacted = SonioxAudioTranscriptionHandler._redact_body_for_logging(body)
assert redacted["webhook_auth_header_value"] == "[REDACTED]"
# Non-secret fields untouched.
assert redacted["model"] == "stt-async-v4"
assert redacted["audio_url"] == "https://example.com/a.wav"
assert redacted["webhook_url"] == "https://example.com/hook"
assert redacted["webhook_auth_header_name"] == "X-Webhook-Auth"
# Original body must not be mutated.
assert body["webhook_auth_header_value"] == "super-secret-token"
def test_redact_helper_should_no_op_when_no_secret_present(self):
body = {"model": "stt-async-v4", "audio_url": "https://example.com/a.wav"}
redacted = SonioxAudioTranscriptionHandler._redact_body_for_logging(body)
assert redacted == body
# Must not introduce a placeholder secret field.
assert "webhook_auth_header_value" not in redacted
def test_redact_helper_should_handle_empty_body(self):
assert SonioxAudioTranscriptionHandler._redact_body_for_logging({}) == {}
def test_redact_helper_should_skip_none_secret_value(self):
# A None-valued secret field is treated as absent (the create-body
# builder already drops Nones, but redact must agree).
body = {"model": "stt-async-v4", "webhook_auth_header_value": None}
redacted = SonioxAudioTranscriptionHandler._redact_body_for_logging(body)
assert redacted["webhook_auth_header_value"] is None
def test_should_redact_secret_in_pre_and_post_call_logging(self, monkeypatch):
"""End-to-end: real request body keeps the secret, logging hooks don't."""
monkeypatch.setattr("time.sleep", lambda *_: None)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_1"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [
_make_response({"text": "ok", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_1": [
_make_response({}),
],
}
client = _MockSyncClient(responses)
logging_obj = _make_logging_obj()
call_kwargs = _common_call_kwargs(client)
call_kwargs["logging_obj"] = logging_obj
SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"webhook_url": "https://example.com/hook",
"webhook_auth_header_name": "X-Webhook-Auth",
"webhook_auth_header_value": "super-secret-token",
},
litellm_params={},
atranscription=False,
**call_kwargs,
)
# 1. Real Soniox request must carry the real secret.
post_call = next(c for c in client.calls if c["method"] == "POST")
assert post_call["json"]["webhook_auth_header_value"] == "super-secret-token"
# 2. Pre-call logging must receive a redacted body.
pre_call_body = logging_obj.pre_call.call_args.kwargs["additional_args"][
"complete_input_dict"
]
assert pre_call_body["webhook_auth_header_value"] == "[REDACTED]"
# Non-secret fields unchanged.
assert pre_call_body["webhook_url"] == "https://example.com/hook"
assert pre_call_body["webhook_auth_header_name"] == "X-Webhook-Auth"
# 3. Post-call logging must also receive a redacted body.
post_call_body = logging_obj.post_call.call_args.kwargs["additional_args"][
"complete_input_dict"
]
assert post_call_body["webhook_auth_header_value"] == "[REDACTED]"
class TestAsyncFlow:
def test_should_run_async_audio_url_flow(self, monkeypatch):
async def _no_sleep(*_a, **_kw):
return None
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_async"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_async": [
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_async/transcript": [
_make_response({"text": "async ok", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_async": [
_make_response({}),
],
}
client = _MockAsyncClient(responses)
coro = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={"audio_url": "https://example.com/a.wav"},
litellm_params={},
atranscription=True,
**_common_call_kwargs(client),
)
resp = asyncio.new_event_loop().run_until_complete(coro)
assert resp.text == "async ok"
assert resp._hidden_params["custom_llm_provider"] == "soniox"
def test_should_run_async_file_upload_flow(self, monkeypatch):
async def _no_sleep(*_a, **_kw):
return None
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
responses = {
"POST https://api.soniox.com/v1/files": [
_make_response({"id": "file_async_1"}),
],
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_async_2"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_async_2": [
_make_response({"status": "queued"}),
_make_response({"status": "completed"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_async_2/transcript": [
_make_response({"text": "async upload ok", "tokens": []}),
],
"DELETE https://api.soniox.com/v1/transcriptions/tx_async_2": [
_make_response({}),
],
"DELETE https://api.soniox.com/v1/files/file_async_1": [
_make_response({}),
],
}
client = _MockAsyncClient(responses)
coro = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=("clip.wav", b"RIFFfake", "audio/wav"),
optional_params={"soniox_polling_interval": 0},
litellm_params={},
atranscription=True,
**_common_call_kwargs(client),
)
resp = asyncio.new_event_loop().run_until_complete(coro)
assert resp.text == "async upload ok"
deletes = [c["url"] for c in client.calls if c["method"] == "DELETE"]
assert "https://api.soniox.com/v1/files/file_async_1" in deletes
def test_should_raise_async_when_status_is_error(self, monkeypatch):
async def _no_sleep(*_a, **_kw):
return None
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_err"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_err": [
_make_response({"status": "error", "error_message": "async boom"}),
],
}
client = _MockAsyncClient(responses)
coro = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_cleanup": [],
},
litellm_params={},
atranscription=True,
**_common_call_kwargs(client),
)
with pytest.raises(SonioxException) as exc_info:
asyncio.new_event_loop().run_until_complete(coro)
assert "async boom" in str(exc_info.value)
def test_should_raise_async_when_polling_attempts_exceeded(self, monkeypatch):
async def _no_sleep(*_a, **_kw):
return None
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
responses = {
"POST https://api.soniox.com/v1/transcriptions": [
_make_response({"id": "tx_timeout"}),
],
"GET https://api.soniox.com/v1/transcriptions/tx_timeout": [
_make_response({"status": "processing"}),
_make_response({"status": "processing"}),
],
}
client = _MockAsyncClient(responses)
coro = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"soniox_polling_interval": 0,
"soniox_max_polling_attempts": 2,
"soniox_cleanup": [],
},
litellm_params={},
atranscription=True,
**_common_call_kwargs(client),
)
with pytest.raises(SonioxException) as exc_info:
asyncio.new_event_loop().run_until_complete(coro)
assert exc_info.value.status_code == 504
def test_should_raise_async_when_no_audio_input_provided(self):
client = _MockAsyncClient({})
coro = SonioxAudioTranscriptionHandler().audio_transcriptions(
audio_file=None,
optional_params={},
litellm_params={},
atranscription=True,
**_common_call_kwargs(client),
)
with pytest.raises(SonioxException) as exc_info:
asyncio.new_event_loop().run_until_complete(coro)
assert exc_info.value.status_code == 400

View file

@ -0,0 +1,495 @@
"""Tests for SonioxAudioTranscriptionConfig."""
import json
from typing import Any, Dict, Optional
from unittest.mock import patch
import httpx
import pytest
from litellm.llms.soniox.audio_transcription.transformation import (
SonioxAudioTranscriptionConfig,
)
from litellm.llms.soniox.common_utils import SonioxException
from litellm.types.utils import TranscriptionResponse
def _make_response(payload: Dict[str, Any], status_code: int = 200) -> httpx.Response:
return httpx.Response(
status_code=status_code,
content=json.dumps(payload).encode("utf-8"),
headers={"content-type": "application/json"},
)
class TestGetSupportedOpenAIParams:
def test_should_advertise_language_and_response_format(self):
cfg = SonioxAudioTranscriptionConfig()
assert cfg.get_supported_openai_params(model="stt-async-v4") == [
"language",
"response_format",
]
class TestMapOpenAIParams:
def test_should_translate_language_to_language_hints(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.map_openai_params(
non_default_params={"language": "en"},
optional_params={},
model="stt-async-v4",
drop_params=False,
)
assert result["language_hints"] == ["en"]
def test_should_prepend_language_to_existing_hints(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.map_openai_params(
non_default_params={"language": "en"},
optional_params={"language_hints": ["fr"]},
model="stt-async-v4",
drop_params=False,
)
assert result["language_hints"] == ["en", "fr"]
def test_should_not_duplicate_language_already_in_hints(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.map_openai_params(
non_default_params={"language": "en"},
optional_params={"language_hints": ["en", "fr"]},
model="stt-async-v4",
drop_params=False,
)
assert result["language_hints"] == ["en", "fr"]
def test_should_passthrough_soniox_native_kwargs(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.map_openai_params(
non_default_params={
"enable_speaker_diarization": True,
"enable_language_identification": True,
"context": "medical conversation",
"audio_url": "https://example.com/a.wav",
},
optional_params={},
model="stt-async-v4",
drop_params=False,
)
assert result["enable_speaker_diarization"] is True
assert result["enable_language_identification"] is True
assert result["context"] == "medical conversation"
assert result["audio_url"] == "https://example.com/a.wav"
def test_should_passthrough_handler_only_kwargs(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.map_openai_params(
non_default_params={
"soniox_polling_interval": 0.5,
"soniox_max_polling_attempts": 10,
"soniox_cleanup": ["file"],
},
optional_params={},
model="stt-async-v4",
drop_params=False,
)
assert result["soniox_polling_interval"] == 0.5
assert result["soniox_max_polling_attempts"] == 10
assert result["soniox_cleanup"] == ["file"]
class TestValidateEnvironment:
def test_should_set_bearer_token_from_api_key(self):
cfg = SonioxAudioTranscriptionConfig()
headers = cfg.validate_environment(
headers={},
model="stt-async-v4",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-test",
)
assert headers["Authorization"] == "Bearer sk-test"
def test_should_resolve_key_from_env(self, monkeypatch):
monkeypatch.setenv("SONIOX_API_KEY", "env-key")
cfg = SonioxAudioTranscriptionConfig()
headers = cfg.validate_environment(
headers={},
model="stt-async-v4",
messages=[],
optional_params={},
litellm_params={},
)
assert headers["Authorization"] == "Bearer env-key"
def test_should_raise_when_no_api_key(self, monkeypatch):
monkeypatch.delenv("SONIOX_API_KEY", raising=False)
cfg = SonioxAudioTranscriptionConfig()
with pytest.raises(SonioxException) as exc_info:
cfg.validate_environment(
headers={},
model="stt-async-v4",
messages=[],
optional_params={},
litellm_params={},
)
assert exc_info.value.status_code == 401
def test_should_merge_caller_headers(self):
cfg = SonioxAudioTranscriptionConfig()
headers = cfg.validate_environment(
headers={"X-Trace-Id": "abc"},
model="stt-async-v4",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-test",
)
assert headers["X-Trace-Id"] == "abc"
assert headers["Authorization"] == "Bearer sk-test"
class TestGetCompleteUrl:
def test_should_return_default_base(self):
cfg = SonioxAudioTranscriptionConfig()
url = cfg.get_complete_url(
api_base=None,
api_key="sk-test",
model="stt-async-v4",
optional_params={},
litellm_params={},
)
assert url == "https://api.soniox.com"
def test_should_strip_trailing_slash_from_custom_base(self):
cfg = SonioxAudioTranscriptionConfig()
url = cfg.get_complete_url(
api_base="https://custom.example.com/",
api_key="sk-test",
model="stt-async-v4",
optional_params={},
litellm_params={},
)
assert url == "https://custom.example.com"
class TestTransformAudioTranscriptionRequest:
def test_should_build_minimal_body_with_model(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.transform_audio_transcription_request(
model="stt-async-v4",
audio_file=None,
optional_params={},
litellm_params={},
)
assert result.data == {"model": "stt-async-v4"}
assert result.files is None
assert result.content_type == "application/json"
def test_should_include_passthrough_params_in_body(self):
cfg = SonioxAudioTranscriptionConfig()
result = cfg.transform_audio_transcription_request(
model="stt-async-v4",
audio_file=None,
optional_params={
"audio_url": "https://example.com/a.wav",
"language_hints": ["en"],
"enable_speaker_diarization": True,
"soniox_polling_interval": 0.5, # handler-only, must NOT appear
},
litellm_params={},
)
body = result.data
assert body["audio_url"] == "https://example.com/a.wav"
assert body["language_hints"] == ["en"]
assert body["enable_speaker_diarization"] is True
assert "soniox_polling_interval" not in body
class TestTransformAudioTranscriptionResponse:
def test_should_build_response_from_plain_transcript_payload(self):
cfg = SonioxAudioTranscriptionConfig()
resp = cfg.transform_audio_transcription_response(
_make_response({"id": "tx_1", "text": "hello world"}),
)
assert resp.text == "hello world"
assert resp["task"] == "transcribe"
def test_should_build_response_from_envelope_payload(self):
cfg = SonioxAudioTranscriptionConfig()
resp = cfg.transform_audio_transcription_response(
_make_response(
{
"transcription": {"id": "tx_1", "audio_duration_ms": 2500},
"transcript": {"text": "hello world", "tokens": []},
}
),
)
assert resp.text == "hello world"
assert resp["duration"] == pytest.approx(2.5)
def test_should_render_speaker_tags_when_diarization_present(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"text": "ignored fallback",
"tokens": [
{"text": "hello", "speaker": 1},
{"text": " world", "speaker": 2},
],
}
}
resp = cfg._build_response_from_payload(payload)
assert "Speaker 1:" in resp.text
assert "Speaker 2:" in resp.text
def test_should_set_language_when_all_tokens_share_one(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"tokens": [
{"text": "hello", "language": "en"},
{"text": " world", "language": "en"},
]
}
}
resp = cfg._build_response_from_payload(payload)
assert resp["language"] == "en"
def test_should_populate_provided_model_response(self):
cfg = SonioxAudioTranscriptionConfig()
model_response = TranscriptionResponse()
model_response._hidden_params = {"pre": "existing"}
payload = {"text": "populated"}
resp = cfg._build_response_from_payload(payload, model_response=model_response)
assert resp is model_response
assert resp.text == "populated"
assert resp._hidden_params["pre"] == "existing"
assert "soniox_raw" in resp._hidden_params
def test_should_stash_raw_payload_in_hidden_params(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcription": {"id": "tx_1"},
"transcript": {"text": "hi", "tokens": []},
}
resp = cfg._build_response_from_payload(payload)
raw = resp._hidden_params["soniox_raw"]
assert raw["transcription"]["id"] == "tx_1"
assert raw["transcript"]["text"] == "hi"
def test_should_raise_on_invalid_json(self):
cfg = SonioxAudioTranscriptionConfig()
bad = httpx.Response(status_code=200, content=b"not json")
with pytest.raises(SonioxException):
cfg.transform_audio_transcription_response(bad)
def test_should_concat_token_texts_when_no_text_field_or_tags(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"tokens": [
{"text": "hello"},
{"text": " world"},
],
}
}
resp = cfg._build_response_from_payload(payload)
assert resp.text == "hello world"
def test_should_return_empty_text_for_empty_payload(self):
cfg = SonioxAudioTranscriptionConfig()
resp = cfg._build_response_from_payload({})
assert resp.text == ""
def test_should_skip_duration_when_audio_duration_ms_is_invalid(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcription": {"audio_duration_ms": "not-a-number"},
"transcript": {"text": "hi", "tokens": []},
}
resp = cfg._build_response_from_payload(payload)
assert "duration" not in resp.model_dump()
class TestRenderSonioxTokens:
def test_should_return_empty_string_for_no_tokens(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens
assert render_soniox_tokens([]) == ""
class TestRenderSonioxTokensAsSrt:
def test_should_render_basic_srt(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": "Hello ", "start_ms": 0, "end_ms": 500},
{"text": "world.", "start_ms": 500, "end_ms": 1000},
]
result = render_soniox_tokens_as_srt(tokens)
assert "1\n" in result
assert "00:00:00,000 --> " in result
assert "Hello world." in result
def test_should_split_cues_on_speaker_change(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": "Hi.", "start_ms": 0, "end_ms": 1000, "speaker": "1"},
{"text": "Hey.", "start_ms": 1500, "end_ms": 2500, "speaker": "2"},
]
result = render_soniox_tokens_as_srt(tokens)
assert "1\n" in result
assert "2\n" in result
assert "Hi." in result
assert "Hey." in result
def test_should_return_empty_string_for_no_timestamps(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [{"text": "no timestamps"}]
result = render_soniox_tokens_as_srt(tokens)
assert result == ""
def test_should_return_empty_string_for_empty_tokens(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
assert render_soniox_tokens_as_srt([]) == ""
def test_should_format_long_timestamps_correctly(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": "Late.", "start_ms": 3661000, "end_ms": 3662000},
]
result = render_soniox_tokens_as_srt(tokens)
# 3661000 ms = 1 hour, 1 minute, 1 second
assert "01:01:01,000" in result
class TestRenderSonioxTokensAsVtt:
def test_should_render_basic_vtt_with_header(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_vtt
tokens = [
{"text": "Hello ", "start_ms": 0, "end_ms": 500},
{"text": "world.", "start_ms": 500, "end_ms": 1000},
]
result = render_soniox_tokens_as_vtt(tokens)
assert result.startswith("WEBVTT\n")
assert "00:00:00.000 --> " in result
assert "Hello world." in result
def test_should_return_header_only_for_empty_tokens(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_vtt
result = render_soniox_tokens_as_vtt([])
assert result.startswith("WEBVTT\n")
# Only header + blank line
lines = result.strip().split("\n")
assert len(lines) == 1
def test_should_use_dot_separator_not_comma(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_vtt
tokens = [{"text": "Test.", "start_ms": 1500, "end_ms": 2500}]
result = render_soniox_tokens_as_vtt(tokens)
# VTT uses dots, not commas
assert "00:00:01.500" in result
assert "," not in result.replace("WEBVTT", "")
class TestBuildResponseWithResponseFormat:
def test_should_render_srt_when_response_format_is_srt(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"tokens": [
{"text": "Hello ", "start_ms": 0, "end_ms": 500},
{"text": "world.", "start_ms": 500, "end_ms": 1000},
]
}
}
resp = cfg._build_response_from_payload(payload, response_format="srt")
assert "00:00:00,000 --> " in resp.text
assert "Hello world." in resp.text
def test_should_render_vtt_when_response_format_is_vtt(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"tokens": [
{"text": "Hello ", "start_ms": 0, "end_ms": 500},
{"text": "world.", "start_ms": 500, "end_ms": 1000},
]
}
}
resp = cfg._build_response_from_payload(payload, response_format="vtt")
assert resp.text.startswith("WEBVTT\n")
assert "Hello world." in resp.text
def test_should_include_words_for_verbose_json(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"text": "Hello world.",
"tokens": [
{"text": "Hello ", "start_ms": 0, "end_ms": 500},
{"text": "world.", "start_ms": 500, "end_ms": 1000},
],
}
}
resp = cfg._build_response_from_payload(payload, response_format="verbose_json")
# text should be plain (not SRT/VTT)
assert resp.text == "Hello world."
# words should be populated
words = resp.get("words")
assert words is not None
assert len(words) == 2
assert words[0]["word"] == "Hello "
assert words[0]["start"] == 0.0
assert words[0]["end"] == 0.5
assert words[1]["start"] == 0.5
assert words[1]["end"] == 1.0
def test_should_default_to_plain_text_when_no_response_format(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"text": "Hello world.",
"tokens": [
{"text": "Hello ", "start_ms": 0, "end_ms": 500},
{"text": "world.", "start_ms": 500, "end_ms": 1000},
],
}
}
resp = cfg._build_response_from_payload(payload, response_format=None)
assert resp.text == "Hello world."
def test_should_fallback_to_plain_text_for_srt_with_no_timestamps(self):
cfg = SonioxAudioTranscriptionConfig()
payload = {
"transcript": {
"text": "No timestamps here.",
"tokens": [{"text": "No timestamps here."}],
}
}
# SRT requested but tokens have no start_ms/end_ms -> empty SRT
# falls back gracefully since _group_tokens_into_cues skips them
resp = cfg._build_response_from_payload(payload, response_format="srt")
# With no timestamp data, SRT rendering produces empty string,
# but we still get output because the code checks `tokens` truthiness
# before choosing SRT path. Actually the tokens list is truthy but
# _group_tokens_into_cues will produce no cues -> empty SRT string.
# Let's verify it doesn't crash.
assert isinstance(resp.text, str)
class TestGetErrorClass:
def test_should_return_soniox_exception(self):
cfg = SonioxAudioTranscriptionConfig()
err = cfg.get_error_class(error_message="boom", status_code=500, headers={})
assert isinstance(err, SonioxException)
assert err.status_code == 500

View file

@ -0,0 +1,42 @@
"""Tests verifying Soniox is correctly registered as a litellm provider."""
import pytest
import litellm
class TestProviderRegistration:
def test_should_expose_soniox_in_llm_providers_enum(self):
assert litellm.LlmProviders.SONIOX.value == "soniox"
def test_should_list_soniox_in_provider_list(self):
assert "soniox" in litellm.provider_list
def test_should_list_soniox_in_models_by_provider(self):
assert "soniox" in litellm.models_by_provider
def test_should_lazy_import_soniox_audio_transcription_config(self):
cls = litellm.SonioxAudioTranscriptionConfig
assert cls.__name__ == "SonioxAudioTranscriptionConfig"
# Calling again should return the same class (cached).
assert litellm.SonioxAudioTranscriptionConfig is cls
def test_should_resolve_soniox_via_get_llm_provider(self, monkeypatch):
monkeypatch.setenv("SONIOX_API_KEY", "test-key")
model, provider, api_key, api_base = litellm.get_llm_provider(
model="soniox/stt-async-v4"
)
assert provider == "soniox"
assert model == "stt-async-v4"
assert api_key == "test-key"
assert api_base == "https://api.soniox.com"
def test_should_return_soniox_config_from_provider_config_manager(self):
from litellm.utils import ProviderConfigManager
cfg = ProviderConfigManager.get_provider_audio_transcription_config(
model="stt-async-v4",
provider=litellm.LlmProviders.SONIOX,
)
assert cfg is not None
assert cfg.__class__.__name__ == "SonioxAudioTranscriptionConfig"

View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 17.5" width="92" fill="white" xmlns="http://www.w3.org/2000/svg"><title>Soniox</title><path d="m0 14.866 2.1606-3.5214c1.8927 1.2576 3.9669 1.8995 5.6694 1.8995 1.0025 0 1.4606-0.3036 1.4606-0.8847v-0.0607c0-0.6419-0.9161-0.9194-2.6532-1.4138-3.2582-0.8587-5.8509-1.9602-5.8509-5.2995v-0.06938c0-3.5214 2.8088-5.4903 6.6114-5.4903 2.4112 0 4.9089 0.70255 6.8016 1.9342l-1.9791 3.6775c-1.7112-0.95408-3.5693-1.5352-4.8744-1.5352-0.88152 0-1.3396 0.33827-1.3396 0.79796v0.06071c0 0.64184 0.94202 0.95409 2.6792 1.4745 3.2582 0.91939 5.8509 2.0556 5.8509 5.2735v0.0607c0 3.6515-2.7137 5.551-6.741 5.551-2.7656-0.0087-5.5052-0.798-7.7955-2.4546z"></path><path d="m16.135 8.7342v-0.06071c0-4.7184 3.8372-8.6735 9.1436-8.6735 5.2719 0 9.0832 3.8944 9.0832 8.6127v0.06072c0 4.7184-3.8372 8.6735-9.1437 8.6735-5.2718 0-9.0831-3.8944-9.0831-8.6128zm12.583 0v-0.06071c0-2.0209-1.4606-3.7383-3.5088-3.7383-2.1001 0-3.4483 1.6826-3.4483 3.6775v0.06072c0 2.0209 1.4605 3.7383 3.5088 3.7383 2.1087 0 3.4483-1.6827 3.4483-3.6776z"></path><path d="m36.877 0.36428h5.7904v2.3332c1.063-1.3791 2.5927-2.6974 4.9348-2.6974 3.5089 0 5.609 2.3332 5.609 6.0974v10.85h-5.7905v-8.977c0-1.8041-0.942-2.7929-2.3161-2.7929-1.4001 0-2.4372 0.9801-2.4372 2.7929v8.977h-5.7904z"></path><path d="m55.951 0.36426h5.7904v16.584h-5.7904z"></path><path d="m64.29 8.7342v-0.06071c0-4.7184 3.8373-8.6735 9.1437-8.6735 5.2719 0 9.0832 3.8944 9.0832 8.6127v0.06072c0 4.7184-3.8372 8.6735-9.1437 8.6735-5.2719 0-9.0832-3.8944-9.0832-8.6128zm12.592 0v-0.06071c0-2.0209-1.4605-3.7383-3.5088-3.7383-2.1001 0-3.4483 1.6826-3.4483 3.6775v0.06072c0 2.0209 1.4606 3.7383 3.5088 3.7383 2.1088 0 3.4483-1.6827 3.4483-3.6776z"></path><path d="m88.082 8.578-5.4533-8.2138h6.2484l2.4372 4.0765 2.4371-4.0765h6.1275l-5.4274 8.1791 5.5484 8.3959h-6.2225l-2.5582-4.2587-2.5927 4.2587h-6.0929z"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -87,6 +87,7 @@ export enum Providers {
Sambanova = "Sambanova",
SAP = "SAP Generative AI Hub",
Snowflake = "Snowflake",
Soniox = "Soniox",
TEXT_COMPLETION_CODESTRAL = "Text-Completion-Codestral",
TogetherAI = "TogetherAI",
TOPAZ = "Topaz",
@ -195,6 +196,7 @@ export const provider_map: Record<string, string> = {
Sambanova: "sambanova",
SAP: "sap",
Snowflake: "snowflake",
Soniox: "soniox",
TEXT_COMPLETION_CODESTRAL: "text-completion-codestral",
TogetherAI: "together_ai",
TOPAZ: "topaz",
@ -286,6 +288,7 @@ export const providerLogoMap: Record<string, string> = {
[Providers.Sambanova]: `${asset_logos_folder}sambanova.svg`,
[Providers.SAP]: `${asset_logos_folder}sap.png`,
[Providers.Snowflake]: `${asset_logos_folder}snowflake.svg`,
[Providers.Soniox]: `${asset_logos_folder}soniox.svg`,
[Providers.TEXT_COMPLETION_CODESTRAL]: `${asset_logos_folder}mistral.svg`,
[Providers.TogetherAI]: `${asset_logos_folder}togetherai.svg`,
[Providers.TOPAZ]: `${asset_logos_folder}topaz.svg`,