From 6b0ad3bed3f933311f59f9743b76182774328d45 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 00:49:34 +0000 Subject: [PATCH 1/7] feat(xai): add speech-to-text via /v1/audio/transcriptions Route xai audio transcription through a provider config hitting POST https://api.x.ai/v1/stt instead of the openai-compatible chat handler which targets /audio/transcriptions. Supports language, diarize, keyterm, filler_words and other provider fields as passthrough kwargs Resolves LIT-8153 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 5 + .../llms/xai/audio_transcription/__init__.py | 3 + .../xai/audio_transcription/transformation.py | 187 ++++++++++++++++++ litellm/main.py | 6 +- ...odel_prices_and_context_window_backup.json | 28 +++ litellm/utils.py | 6 + model_prices_and_context_window.json | 28 +++ ..._xai_audio_transcription_transformation.py | 172 ++++++++++++++++ 8 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 litellm/llms/xai/audio_transcription/__init__.py create mode 100644 litellm/llms/xai/audio_transcription/transformation.py create mode 100644 tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py diff --git a/litellm/constants.py b/litellm/constants.py index a7d4eba0f15..624828d6eb0 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -998,6 +998,11 @@ openai_compatible_providers: Final[list] = [ "cognition", "scx-ai", ] + +# Providers that are openai-compatible for chat but have their own audio +# transcription endpoint, so litellm.transcription must route them through +# their provider config instead of the OpenAI SDK handler. +OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION: Final = frozenset({"xai"}) openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", "fireworks_ai", diff --git a/litellm/llms/xai/audio_transcription/__init__.py b/litellm/llms/xai/audio_transcription/__init__.py new file mode 100644 index 00000000000..c7910cf1f6b --- /dev/null +++ b/litellm/llms/xai/audio_transcription/__init__.py @@ -0,0 +1,3 @@ +from .transformation import XAIAudioTranscriptionConfig + +__all__ = ["XAIAudioTranscriptionConfig"] diff --git a/litellm/llms/xai/audio_transcription/transformation.py b/litellm/llms/xai/audio_transcription/transformation.py new file mode 100644 index 00000000000..8f977dd99fa --- /dev/null +++ b/litellm/llms/xai/audio_transcription/transformation.py @@ -0,0 +1,187 @@ +""" +Translates from OpenAI's `/v1/audio/transcriptions` to xAI's `/v1/stt` +""" + +from collections.abc import Iterable, Mapping +from typing import Final, cast + +from httpx import Headers, Response +from pydantic import BaseModel, ConfigDict + +import litellm +from litellm.litellm_core_utils.audio_utils.utils import process_audio_file +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIAudioTranscriptionOptionalParams, +) +from litellm.types.utils import FileTypes, TranscriptionResponse + +from ...base_llm.audio_transcription.transformation import ( + AudioTranscriptionRequestData, + BaseAudioTranscriptionConfig, +) +from ..common_utils import XAIModelInfo + + +class XAIAudioTranscriptionError(BaseLLMException): + pass + + +class _XAISttWord(BaseModel): + model_config = ConfigDict(extra="allow") + text: str = "" + start: float = 0.0 + end: float = 0.0 + speaker: str | None = None + + +class _XAISttResponse(BaseModel): + model_config = ConfigDict(extra="allow") + text: str = "" + language: str = "unknown" + duration: float | None = None + words: list[_XAISttWord] | None = None + + +def _serialize_form_value(value: object) -> str | list[str]: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + return [str(item) for item in cast(Iterable[object], value)] + return str(value) + + +class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): + @property + def custom_llm_provider(self) -> str: + return litellm.LlmProviders.XAI.value + + def get_supported_openai_params(self, model: str) -> list[OpenAIAudioTranscriptionOptionalParams]: + return ["language"] + + def map_openai_params( + self, + non_default_params: dict[str, object], + optional_params: dict[str, object], + model: str, + drop_params: bool, + ) -> dict[str, object]: + supported_params: Final = self.get_supported_openai_params(model) + for k, v in non_default_params.items(): + if k in supported_params: + optional_params[k] = v + return optional_params + + def get_error_class( + self, error_message: str, status_code: int, headers: dict[str, object] | Headers + ) -> BaseLLMException: + return XAIAudioTranscriptionError(message=error_message, status_code=status_code, headers=headers) + + def transform_audio_transcription_request( + self, + model: str, + audio_file: FileTypes, + optional_params: dict[str, object], + litellm_params: dict[str, object], + ) -> AudioTranscriptionRequestData: + processed_audio: Final = process_audio_file(audio_file) + + # Provider kwargs land in `extra_body` for openai_compatible_providers + extra_body: Final = optional_params.get("extra_body") + flat_params: Final[dict[str, object]] = { + **(dict(cast(Mapping[str, object], extra_body)) if isinstance(extra_body, Mapping) else {}), + **{k: v for k, v in optional_params.items() if k != "extra_body"}, + } + + openai_params: Final = self.get_supported_openai_params(model) + excluded_params: Final = frozenset({"model", "OPENAI_TRANSCRIPTION_PARAMS", *openai_params}) + provider_specific_params: Final[dict[str, object]] = { + k: v for k, v in flat_params.items() if v is not None and k not in excluded_params + } + + form_data: Final[dict[str, str | list[str]]] = {"model": model} + for key, value in provider_specific_params.items(): + form_data[key] = _serialize_form_value(value) + for key in openai_params: + value = flat_params.get(key) + if value is not None: + form_data[key] = _serialize_form_value(value) + + files: Final = { + "file": ( + processed_audio.filename, + processed_audio.file_content, + processed_audio.content_type, + ) + } + + return AudioTranscriptionRequestData(data=form_data, files=files) + + def transform_audio_transcription_response( + self, + raw_response: Response, + ) -> TranscriptionResponse: + try: + payload: Final = _XAISttResponse.model_validate_json(raw_response.content) + except Exception as e: + raise XAIAudioTranscriptionError( + message=f"Error parsing xAI response: {e}", + status_code=raw_response.status_code, + headers=dict(raw_response.headers), + ) + + response: Final = TranscriptionResponse(text=payload.text) + response["task"] = "transcribe" + response["language"] = payload.language + + if payload.duration is not None: + response["duration"] = payload.duration + + if payload.words is not None: + response["words"] = [ + { + "word": word.text, + "start": word.start, + "end": word.end, + **({"speaker": word.speaker} if word.speaker is not None else {}), + } + for word in payload.words + ] + + hidden_params: Final[dict[str, object]] = dict(payload.model_dump(mode="json")) + if payload.duration is not None: + hidden_params["audio_transcription_duration"] = payload.duration + response._hidden_params = hidden_params # pyright: ignore[reportPrivateUsage] # TranscriptionResponse exposes no public hidden-params setter + + return response + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: dict[str, object], + litellm_params: dict[str, object], + stream: bool | None = None, + ) -> str: + base: Final = (XAIModelInfo.get_api_base(api_base) or "").rstrip("/") + normalized: Final = base.removesuffix("/v1") + return f"{normalized}/v1/stt" + + def validate_environment( + self, + headers: dict[str, object], + model: str, + messages: list[AllMessageValues], + optional_params: dict[str, object], + litellm_params: dict[str, object], + api_key: str | None = None, + api_base: str | None = None, + ) -> dict[str, object]: + resolved_key: Final = XAIModelInfo.get_api_key(api_key) + if resolved_key is None: + raise ValueError("xAI API key is required. Set XAI_API_KEY environment variable.") + + headers["Authorization"] = f"Bearer {resolved_key}" + return headers diff --git a/litellm/main.py b/litellm/main.py index ac8fa507728..1c3de8e766b 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -64,6 +64,7 @@ from litellm.constants import ( AZURE_OPENAI_AUDIO_PROVIDERS, DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT, DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT, + OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION, ) from litellm.exceptions import LiteLLMUnknownProvider from litellm.integrations.custom_logger import CustomLogger @@ -7859,7 +7860,10 @@ def transcription( litellm_params=litellm_params_dict, custom_llm_provider=custom_llm_provider, ) - elif custom_llm_provider == "openai" or (custom_llm_provider in litellm.openai_compatible_providers): + elif custom_llm_provider == "openai" or ( + custom_llm_provider in litellm.openai_compatible_providers + and custom_llm_provider not in OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION + ): api_base = ( api_base or litellm.api_base diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 30b08e54410..f8dc79e6c04 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -63375,6 +63375,34 @@ "video" ] }, + "xai/grok-voice-transcribe-1.0": { + "input_cost_per_second": 2.778e-05, + "litellm_provider": "xai", + "metadata": { + "calculation": "$0.10/3600 seconds = $0.00002778 per second", + "original_pricing_per_hour": 0.1 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://docs.x.ai/developers/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "xai/grok-voice-transcribe-2.0": { + "input_cost_per_second": 2.778e-05, + "litellm_provider": "xai", + "metadata": { + "calculation": "$0.10/3600 seconds = $0.00002778 per second", + "original_pricing_per_hour": 0.1 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://docs.x.ai/developers/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, "low/1024-x-1024/grok-imagine-image-2.0": { "input_cost_per_image": 0.04, "litellm_provider": "xai", diff --git a/litellm/utils.py b/litellm/utils.py index f2315651a53..97c074ce112 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8730,6 +8730,12 @@ class ProviderConfigManager: ) return ElevenLabsAudioTranscriptionConfig() + elif litellm.LlmProviders.XAI == provider: + from litellm.llms.xai.audio_transcription.transformation import ( + XAIAudioTranscriptionConfig, + ) + + return XAIAudioTranscriptionConfig() elif litellm.LlmProviders.OPENAI == provider: if "gpt-4o" in model: return litellm.OpenAIGPTAudioTranscriptionConfig() diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 30b08e54410..f8dc79e6c04 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -63375,6 +63375,34 @@ "video" ] }, + "xai/grok-voice-transcribe-1.0": { + "input_cost_per_second": 2.778e-05, + "litellm_provider": "xai", + "metadata": { + "calculation": "$0.10/3600 seconds = $0.00002778 per second", + "original_pricing_per_hour": 0.1 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://docs.x.ai/developers/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "xai/grok-voice-transcribe-2.0": { + "input_cost_per_second": 2.778e-05, + "litellm_provider": "xai", + "metadata": { + "calculation": "$0.10/3600 seconds = $0.00002778 per second", + "original_pricing_per_hour": 0.1 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://docs.x.ai/developers/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, "low/1024-x-1024/grok-imagine-image-2.0": { "input_cost_per_image": 0.04, "litellm_provider": "xai", diff --git a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py new file mode 100644 index 00000000000..0fce47050b5 --- /dev/null +++ b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py @@ -0,0 +1,172 @@ +import httpx +import pytest + +import litellm +from litellm.llms.base_llm.audio_transcription.transformation import ( + AudioTranscriptionRequestData, +) +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.xai.audio_transcription.transformation import ( + XAIAudioTranscriptionConfig, +) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +CONFIG = XAIAudioTranscriptionConfig() + +WAV_BYTES = b"RIFF" + b"\x00" * 64 + + +def test_transform_request_serializes_provider_params(): + result = CONFIG.transform_audio_transcription_request( + model="grok-voice-transcribe-2.0", + audio_file=WAV_BYTES, + optional_params={ + "language": "en", + "diarize": True, + "keyterm": ["LiteLLM", "Grok"], + }, + litellm_params={}, + ) + + assert isinstance(result, AudioTranscriptionRequestData) + data = result.data + assert data["model"] == "grok-voice-transcribe-2.0" + assert data["language"] == "en" + assert data["diarize"] == "true" + assert data["keyterm"] == ["LiteLLM", "Grok"] + filename, content, content_type = result.files["file"] + assert content == WAV_BYTES + assert isinstance(filename, str) + assert isinstance(content_type, str) + + +def test_transform_request_flattens_extra_body(): + result = CONFIG.transform_audio_transcription_request( + model="grok-voice-transcribe-1.0", + audio_file=WAV_BYTES, + optional_params={ + "language": "en", + "extra_body": {"diarize": False, "channels": 2}, + }, + litellm_params={}, + ) + assert result.data["diarize"] == "false" + assert result.data["channels"] == "2" + assert "extra_body" not in result.data + + +@pytest.mark.parametrize( + "api_base,expected", + [ + (None, "https://api.x.ai/v1/stt"), + ("https://api.x.ai/v1", "https://api.x.ai/v1/stt"), + ("https://api.x.ai/v1/", "https://api.x.ai/v1/stt"), + ("https://proxy.example/", "https://proxy.example/v1/stt"), + ], +) +def test_get_complete_url(api_base, expected): + url = CONFIG.get_complete_url( + api_base=api_base, + api_key=None, + model="grok-voice-transcribe-2.0", + optional_params={}, + litellm_params={}, + ) + assert url == expected + + +def test_validate_environment_sets_bearer_header(): + headers = CONFIG.validate_environment( + headers={}, + model="grok-voice-transcribe-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-test", + ) + assert headers["Authorization"] == "Bearer sk-test" + assert "Content-Type" not in headers + + +def test_validate_environment_requires_key(monkeypatch): + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.setattr(litellm, "xai_key", None) + with pytest.raises(ValueError): + CONFIG.validate_environment( + headers={}, + model="grok-voice-transcribe-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + ) + + +def test_transform_response_maps_xai_shape(): + raw = httpx.Response( + 200, + json={ + "text": "hello world", + "language": "en", + "duration": 3.2, + "words": [ + {"text": "hello", "start": 0.0, "end": 0.5, "speaker": "1"}, + {"text": "world", "start": 0.5, "end": 1.0}, + ], + }, + request=httpx.Request("POST", "https://api.x.ai/v1/stt"), + ) + response = CONFIG.transform_audio_transcription_response(raw_response=raw) + + assert response.text == "hello world" + assert response["language"] == "en" + assert response["duration"] == 3.2 + assert response["task"] == "transcribe" + assert response["words"] == [ + {"word": "hello", "start": 0.0, "end": 0.5, "speaker": "1"}, + {"word": "world", "start": 0.5, "end": 1.0}, + ] + assert response._hidden_params["audio_transcription_duration"] == 3.2 + + +def test_transcription_routes_to_xai_stt(monkeypatch): + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.setattr(litellm, "xai_key", None) + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response( + 200, + json={"text": "transcribed text", "language": "en", "duration": 1.5}, + request=request, + ) + + http_handler = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))) + response = litellm.transcription( + model="xai/grok-voice-transcribe-2.0", + file=("sample.wav", WAV_BYTES, "audio/wav"), + api_key="sk-test", + diarize=True, + keyterm=["LiteLLM"], + client=http_handler, + ) + + request = captured["request"] + assert str(request.url) == "https://api.x.ai/v1/stt" + assert request.headers["Authorization"] == "Bearer sk-test" + body = request.content.decode("utf-8", errors="replace") + assert 'name="model"' in body and "grok-voice-transcribe-2.0" in body + assert 'name="diarize"' in body and "true" in body + assert 'name="keyterm"' in body and "LiteLLM" in body + assert 'name="file"' in body + assert response.text == "transcribed text" + + +def test_provider_config_manager_returns_xai_config(): + config = ProviderConfigManager.get_provider_audio_transcription_config( + model="grok-voice-transcribe-2.0", + provider=LlmProviders.XAI, + ) + assert isinstance(config, XAIAudioTranscriptionConfig) From 6f54ad5166ab55358a91bef2ad96cce3a4efba9b Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 00:53:02 +0000 Subject: [PATCH 2/7] fix(xai): parse integer speaker ids and simplify stt form build Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 4 +- .../xai/audio_transcription/transformation.py | 90 ++++++++++--------- ..._xai_audio_transcription_transformation.py | 4 +- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 624828d6eb0..56d3f5450d7 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -999,10 +999,8 @@ openai_compatible_providers: Final[list] = [ "scx-ai", ] -# Providers that are openai-compatible for chat but have their own audio -# transcription endpoint, so litellm.transcription must route them through -# their provider config instead of the OpenAI SDK handler. OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION: Final = frozenset({"xai"}) + openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", "fireworks_ai", diff --git a/litellm/llms/xai/audio_transcription/transformation.py b/litellm/llms/xai/audio_transcription/transformation.py index 8f977dd99fa..b5c5d9c522d 100644 --- a/litellm/llms/xai/audio_transcription/transformation.py +++ b/litellm/llms/xai/audio_transcription/transformation.py @@ -2,11 +2,11 @@ Translates from OpenAI's `/v1/audio/transcriptions` to xAI's `/v1/stt` """ -from collections.abc import Iterable, Mapping -from typing import Final, cast +from collections.abc import Mapping, Sequence +from typing import Final from httpx import Headers, Response -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError import litellm from litellm.litellm_core_utils.audio_utils.utils import process_audio_file @@ -33,7 +33,7 @@ class _XAISttWord(BaseModel): text: str = "" start: float = 0.0 end: float = 0.0 - speaker: str | None = None + speaker: int | None = None class _XAISttResponse(BaseModel): @@ -41,14 +41,18 @@ class _XAISttResponse(BaseModel): text: str = "" language: str = "unknown" duration: float | None = None - words: list[_XAISttWord] | None = None + words: tuple[_XAISttWord, ...] | None = None -def _serialize_form_value(value: object) -> str | list[str]: +_OBJECT_TUPLE: Final = TypeAdapter(tuple[object, ...]) +_STRING_OBJECT_DICT: Final = TypeAdapter(dict[str, object]) + + +def _serialize_form_value(value: object) -> str | list[str]: # mutable-ok: httpx multipart data takes list values for repeated form fields if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (list, tuple)): - return [str(item) for item in cast(Iterable[object], value)] + return [str(item) for item in _OBJECT_TUPLE.validate_python(value)] return str(value) @@ -57,24 +61,24 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): def custom_llm_provider(self) -> str: return litellm.LlmProviders.XAI.value - def get_supported_openai_params(self, model: str) -> list[OpenAIAudioTranscriptionOptionalParams]: + def get_supported_openai_params(self, model: str) -> list[OpenAIAudioTranscriptionOptionalParams]: # mutable-ok: base class signature returns list return ["language"] def map_openai_params( self, - non_default_params: dict[str, object], - optional_params: dict[str, object], + non_default_params: Mapping[str, object], + optional_params: Mapping[str, object], model: str, drop_params: bool, - ) -> dict[str, object]: + ) -> dict[str, object]: # mutable-ok: base class signature returns dict supported_params: Final = self.get_supported_openai_params(model) - for k, v in non_default_params.items(): - if k in supported_params: - optional_params[k] = v - return optional_params + return { + **optional_params, + **{k: v for k, v in non_default_params.items() if k in supported_params}, + } def get_error_class( - self, error_message: str, status_code: int, headers: dict[str, object] | Headers + self, error_message: str, status_code: int, headers: dict[str, object] | Headers # mutable-ok: base class signature takes dict ) -> BaseLLMException: return XAIAudioTranscriptionError(message=error_message, status_code=status_code, headers=headers) @@ -82,32 +86,31 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): self, model: str, audio_file: FileTypes, - optional_params: dict[str, object], - litellm_params: dict[str, object], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], ) -> AudioTranscriptionRequestData: processed_audio: Final = process_audio_file(audio_file) - # Provider kwargs land in `extra_body` for openai_compatible_providers extra_body: Final = optional_params.get("extra_body") - flat_params: Final[dict[str, object]] = { - **(dict(cast(Mapping[str, object], extra_body)) if isinstance(extra_body, Mapping) else {}), + flat_params: Final[Mapping[str, object]] = { + **( + _STRING_OBJECT_DICT.validate_python(extra_body) + if isinstance(extra_body, Mapping) + else {} + ), **{k: v for k, v in optional_params.items() if k != "extra_body"}, } - openai_params: Final = self.get_supported_openai_params(model) - excluded_params: Final = frozenset({"model", "OPENAI_TRANSCRIPTION_PARAMS", *openai_params}) - provider_specific_params: Final[dict[str, object]] = { - k: v for k, v in flat_params.items() if v is not None and k not in excluded_params + excluded_params: Final = frozenset({"model", "OPENAI_TRANSCRIPTION_PARAMS", "extra_body"}) + form_data: Final[dict[str, str | list[str]]] = { # mutable-ok: AudioTranscriptionRequestData.data requires dict and httpx needs list values + "model": model, + **{ + k: _serialize_form_value(v) + for k, v in flat_params.items() + if v is not None and k not in excluded_params + }, } - form_data: Final[dict[str, str | list[str]]] = {"model": model} - for key, value in provider_specific_params.items(): - form_data[key] = _serialize_form_value(value) - for key in openai_params: - value = flat_params.get(key) - if value is not None: - form_data[key] = _serialize_form_value(value) - files: Final = { "file": ( processed_audio.filename, @@ -124,7 +127,7 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): ) -> TranscriptionResponse: try: payload: Final = _XAISttResponse.model_validate_json(raw_response.content) - except Exception as e: + except ValidationError as e: raise XAIAudioTranscriptionError( message=f"Error parsing xAI response: {e}", status_code=raw_response.status_code, @@ -149,7 +152,7 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): for word in payload.words ] - hidden_params: Final[dict[str, object]] = dict(payload.model_dump(mode="json")) + hidden_params: Final[dict[str, object]] = dict(payload.model_dump(mode="json")) # mutable-ok: TranscriptionResponse._hidden_params is a dict if payload.duration is not None: hidden_params["audio_transcription_duration"] = payload.duration response._hidden_params = hidden_params # pyright: ignore[reportPrivateUsage] # TranscriptionResponse exposes no public hidden-params setter @@ -161,8 +164,8 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): api_base: str | None, api_key: str | None, model: str, - optional_params: dict[str, object], - litellm_params: dict[str, object], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], stream: bool | None = None, ) -> str: base: Final = (XAIModelInfo.get_api_base(api_base) or "").rstrip("/") @@ -171,17 +174,16 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): def validate_environment( self, - headers: dict[str, object], + headers: dict[str, object], # mutable-ok: base class signature takes and returns dict model: str, - messages: list[AllMessageValues], - optional_params: dict[str, object], - litellm_params: dict[str, object], + messages: Sequence[AllMessageValues], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], api_key: str | None = None, api_base: str | None = None, - ) -> dict[str, object]: + ) -> dict[str, object]: # mutable-ok: base class signature returns dict resolved_key: Final = XAIModelInfo.get_api_key(api_key) if resolved_key is None: raise ValueError("xAI API key is required. Set XAI_API_KEY environment variable.") - headers["Authorization"] = f"Bearer {resolved_key}" - return headers + return {**headers, "Authorization": f"Bearer {resolved_key}"} diff --git a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py index 0fce47050b5..f2365f00ba3 100644 --- a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py @@ -111,7 +111,7 @@ def test_transform_response_maps_xai_shape(): "language": "en", "duration": 3.2, "words": [ - {"text": "hello", "start": 0.0, "end": 0.5, "speaker": "1"}, + {"text": "hello", "start": 0.0, "end": 0.5, "speaker": 1}, {"text": "world", "start": 0.5, "end": 1.0}, ], }, @@ -124,7 +124,7 @@ def test_transform_response_maps_xai_shape(): assert response["duration"] == 3.2 assert response["task"] == "transcribe" assert response["words"] == [ - {"word": "hello", "start": 0.0, "end": 0.5, "speaker": "1"}, + {"word": "hello", "start": 0.0, "end": 0.5, "speaker": 1}, {"word": "world", "start": 0.5, "end": 1.0}, ] assert response._hidden_params["audio_transcription_duration"] == 3.2 From 4565bbee2f2837e2acde26f969fb4b52f739e62c Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 00:58:44 +0000 Subject: [PATCH 3/7] style(xai): ruff format stt transformation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../xai/audio_transcription/transformation.py | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/litellm/llms/xai/audio_transcription/transformation.py b/litellm/llms/xai/audio_transcription/transformation.py index b5c5d9c522d..03c06f24a2d 100644 --- a/litellm/llms/xai/audio_transcription/transformation.py +++ b/litellm/llms/xai/audio_transcription/transformation.py @@ -48,7 +48,9 @@ _OBJECT_TUPLE: Final = TypeAdapter(tuple[object, ...]) _STRING_OBJECT_DICT: Final = TypeAdapter(dict[str, object]) -def _serialize_form_value(value: object) -> str | list[str]: # mutable-ok: httpx multipart data takes list values for repeated form fields +def _serialize_form_value( + value: object, +) -> str | list[str]: # mutable-ok: httpx multipart data takes list values for repeated form fields if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (list, tuple)): @@ -61,7 +63,9 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): def custom_llm_provider(self) -> str: return litellm.LlmProviders.XAI.value - def get_supported_openai_params(self, model: str) -> list[OpenAIAudioTranscriptionOptionalParams]: # mutable-ok: base class signature returns list + def get_supported_openai_params( + self, model: str + ) -> list[OpenAIAudioTranscriptionOptionalParams]: # mutable-ok: base class signature returns list return ["language"] def map_openai_params( @@ -78,7 +82,10 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): } def get_error_class( - self, error_message: str, status_code: int, headers: dict[str, object] | Headers # mutable-ok: base class signature takes dict + self, + error_message: str, + status_code: int, + headers: dict[str, object] | Headers, # mutable-ok: base class signature takes dict ) -> BaseLLMException: return XAIAudioTranscriptionError(message=error_message, status_code=status_code, headers=headers) @@ -93,16 +100,14 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): extra_body: Final = optional_params.get("extra_body") flat_params: Final[Mapping[str, object]] = { - **( - _STRING_OBJECT_DICT.validate_python(extra_body) - if isinstance(extra_body, Mapping) - else {} - ), + **(_STRING_OBJECT_DICT.validate_python(extra_body) if isinstance(extra_body, Mapping) else {}), **{k: v for k, v in optional_params.items() if k != "extra_body"}, } excluded_params: Final = frozenset({"model", "OPENAI_TRANSCRIPTION_PARAMS", "extra_body"}) - form_data: Final[dict[str, str | list[str]]] = { # mutable-ok: AudioTranscriptionRequestData.data requires dict and httpx needs list values + form_data: Final[ + dict[str, str | list[str]] + ] = { # mutable-ok: AudioTranscriptionRequestData.data requires dict and httpx needs list values "model": model, **{ k: _serialize_form_value(v) @@ -152,7 +157,9 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): for word in payload.words ] - hidden_params: Final[dict[str, object]] = dict(payload.model_dump(mode="json")) # mutable-ok: TranscriptionResponse._hidden_params is a dict + hidden_params: Final[dict[str, object]] = dict( + payload.model_dump(mode="json") + ) # mutable-ok: TranscriptionResponse._hidden_params is a dict if payload.duration is not None: hidden_params["audio_transcription_duration"] = payload.duration response._hidden_params = hidden_params # pyright: ignore[reportPrivateUsage] # TranscriptionResponse exposes no public hidden-params setter From 80b0ea6a2f4601e0217786019d2fe37f4b1da83b Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 01:00:53 +0000 Subject: [PATCH 4/7] test(xai): narrow raises match for missing api key Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/xai/test_xai_audio_transcription_transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py index f2365f00ba3..0f3445eb400 100644 --- a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py @@ -92,7 +92,7 @@ def test_validate_environment_sets_bearer_header(): def test_validate_environment_requires_key(monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr(litellm, "xai_key", None) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="xAI API key is required"): CONFIG.validate_environment( headers={}, model="grok-voice-transcribe-2.0", From a124e079c369723fd236c8ae59cf5ed80624a6c3 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 01:06:09 +0000 Subject: [PATCH 5/7] refactor(xai): use derived provider set for transcription routing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 4 ++++ litellm/main.py | 7 ++----- litellm/utils.py | 4 +--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 56d3f5450d7..55f92f29c96 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1001,6 +1001,10 @@ openai_compatible_providers: Final[list] = [ OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION: Final = frozenset({"xai"}) +OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset( + {"openai"} | (frozenset(openai_compatible_providers) - OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION) +) + openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", "fireworks_ai", diff --git a/litellm/main.py b/litellm/main.py index 1c3de8e766b..bd10c3924f7 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -64,7 +64,7 @@ from litellm.constants import ( AZURE_OPENAI_AUDIO_PROVIDERS, DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT, DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT, - OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION, + OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS, ) from litellm.exceptions import LiteLLMUnknownProvider from litellm.integrations.custom_logger import CustomLogger @@ -7860,10 +7860,7 @@ def transcription( litellm_params=litellm_params_dict, custom_llm_provider=custom_llm_provider, ) - elif custom_llm_provider == "openai" or ( - custom_llm_provider in litellm.openai_compatible_providers - and custom_llm_provider not in OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION - ): + elif custom_llm_provider in OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: api_base = ( api_base or litellm.api_base diff --git a/litellm/utils.py b/litellm/utils.py index 97c074ce112..e30e8cde86d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8731,9 +8731,7 @@ class ProviderConfigManager: return ElevenLabsAudioTranscriptionConfig() elif litellm.LlmProviders.XAI == provider: - from litellm.llms.xai.audio_transcription.transformation import ( - XAIAudioTranscriptionConfig, - ) + from litellm.llms.xai.audio_transcription.transformation import XAIAudioTranscriptionConfig return XAIAudioTranscriptionConfig() elif litellm.LlmProviders.OPENAI == provider: From 99d91d72056a43a95e6f377e9dda02df69d111d5 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 01:22:25 +0000 Subject: [PATCH 6/7] fix(xai): reject non-success stt responses before parsing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../xai/audio_transcription/transformation.py | 7 +++++++ ...est_xai_audio_transcription_transformation.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/litellm/llms/xai/audio_transcription/transformation.py b/litellm/llms/xai/audio_transcription/transformation.py index 03c06f24a2d..7b648fc8084 100644 --- a/litellm/llms/xai/audio_transcription/transformation.py +++ b/litellm/llms/xai/audio_transcription/transformation.py @@ -130,6 +130,13 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): self, raw_response: Response, ) -> TranscriptionResponse: + if raw_response.status_code >= 400: + raise self.get_error_class( + error_message=raw_response.text, + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + try: payload: Final = _XAISttResponse.model_validate_json(raw_response.content) except ValidationError as e: diff --git a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py index 0f3445eb400..e2e3fc3d4dc 100644 --- a/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/xai/test_xai_audio_transcription_transformation.py @@ -8,6 +8,7 @@ from litellm.llms.base_llm.audio_transcription.transformation import ( from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.xai.audio_transcription.transformation import ( XAIAudioTranscriptionConfig, + XAIAudioTranscriptionError, ) from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -130,6 +131,21 @@ def test_transform_response_maps_xai_shape(): assert response._hidden_params["audio_transcription_duration"] == 3.2 +def test_transform_response_raises_on_error_status(): + raw = httpx.Response( + 400, + json={ + "code": "Client specified an invalid argument", + "error": "Incorrect API key provided", + }, + request=httpx.Request("POST", "https://api.x.ai/v1/stt"), + ) + with pytest.raises(XAIAudioTranscriptionError) as exc: + CONFIG.transform_audio_transcription_response(raw_response=raw) + assert exc.value.status_code == 400 + assert "Incorrect API key provided" in exc.value.message + + def test_transcription_routes_to_xai_stt(monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr(litellm, "xai_key", None) From 4e38f1845d8e972b330009fb1f70cbde35afb838 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 01:40:00 +0000 Subject: [PATCH 7/7] refactor(xai): move native stt routing opt-out behind the provider config Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 6 +----- .../llms/base_llm/audio_transcription/transformation.py | 9 +++++++++ litellm/llms/xai/audio_transcription/transformation.py | 4 ++++ litellm/main.py | 6 +++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 55f92f29c96..83dd91c9b7d 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -999,11 +999,7 @@ openai_compatible_providers: Final[list] = [ "scx-ai", ] -OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION: Final = frozenset({"xai"}) - -OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset( - {"openai"} | (frozenset(openai_compatible_providers) - OPENAI_COMPATIBLE_PROVIDERS_WITH_NATIVE_AUDIO_TRANSCRIPTION) -) +OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset({"openai"} | frozenset(openai_compatible_providers)) openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/llms/base_llm/audio_transcription/transformation.py b/litellm/llms/base_llm/audio_transcription/transformation.py index b323c4812b5..2296909cfe1 100644 --- a/litellm/llms/base_llm/audio_transcription/transformation.py +++ b/litellm/llms/base_llm/audio_transcription/transformation.py @@ -52,6 +52,15 @@ class BaseAudioTranscriptionConfig(BaseConfig, ABC): """ return False + @property + def has_native_transcription_endpoint(self) -> bool: + """ + Opt-in for OpenAI-compatible providers whose transcription lives on a + non-OpenAI route: when True the request skips the OpenAI SDK transport + and goes through this config via the shared http handler. + """ + return False + def get_complete_url( self, api_base: str | None, diff --git a/litellm/llms/xai/audio_transcription/transformation.py b/litellm/llms/xai/audio_transcription/transformation.py index 7b648fc8084..feeabed0d9c 100644 --- a/litellm/llms/xai/audio_transcription/transformation.py +++ b/litellm/llms/xai/audio_transcription/transformation.py @@ -63,6 +63,10 @@ class XAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig): def custom_llm_provider(self) -> str: return litellm.LlmProviders.XAI.value + @property + def has_native_transcription_endpoint(self) -> bool: + return True + def get_supported_openai_params( self, model: str ) -> list[OpenAIAudioTranscriptionOptionalParams]: # mutable-ok: base class signature returns list diff --git a/litellm/main.py b/litellm/main.py index bd10c3924f7..38184db1d10 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7831,6 +7831,10 @@ def transcription( provider=LlmProviders(custom_llm_provider), ) + uses_openai_transport: Final = custom_llm_provider in OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS and not ( + provider_config is not None and provider_config.has_native_transcription_endpoint + ) + if custom_llm_provider in AZURE_OPENAI_AUDIO_PROVIDERS and provider_config is None: # azure configs api_base = api_base or litellm.api_base or get_secret_str("AZURE_API_BASE") @@ -7860,7 +7864,7 @@ def transcription( litellm_params=litellm_params_dict, custom_llm_provider=custom_llm_provider, ) - elif custom_llm_provider in OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: + elif uses_openai_transport: api_base = ( api_base or litellm.api_base