From c9d565b4dd4031bc078cb5eecc9add9adb6c14b5 Mon Sep 17 00:00:00 2001 From: RohanDisa <105740583+RohanDisa@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:15:16 -0700 Subject: [PATCH] feat: add OpenRouter TTS support to speech() --- litellm/main.py | 44 ++++++ .../test_openrouter_audio_speech.py | 129 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/test_litellm/llms/openrouter/test_openrouter_audio_speech.py diff --git a/litellm/main.py b/litellm/main.py index b1aaf5c5dab..5d50c67e1cd 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8502,6 +8502,50 @@ def speech( api_key=api_key, **kwargs, ) + elif custom_llm_provider == "openrouter": + if voice is None or not (isinstance(voice, str)): + raise litellm.BadRequestError( + message="'voice' is required to be passed as a string for OpenRouter TTS", + model=model, + llm_provider=custom_llm_provider, + ) + openrouter_api_base: Final = ( + api_base or litellm.api_base or get_secret_str("OPENROUTER_API_BASE") or "https://openrouter.ai/api/v1" + ) + # set API KEY + openrouter_api_key: Final = ( + api_key or litellm.api_key or litellm.openrouter_key or get_secret_str("OPENROUTER_API_KEY") + ) + + OPENROUTER_DEFAULT_SITE_URL: Final = "https://litellm.ai" + OPENROUTER_DEFAULT_APP_NAME: Final = "liteLLM" + openrouter_site_url: Final = get_secret("OR_SITE_URL") or OPENROUTER_DEFAULT_SITE_URL + openrouter_app_name: Final = get_secret("OR_APP_NAME") or OPENROUTER_DEFAULT_APP_NAME + openrouter_headers: Final = { + "HTTP-Referer": openrouter_site_url, + "X-Title": openrouter_app_name, + } + if extra_headers: + openrouter_headers.update(extra_headers) + optional_params["extra_headers"] = openrouter_headers + + openrouter_response: Final = openai_chat_completions.audio_speech( + model=model, + input=input, + voice=voice, + optional_params=optional_params, + api_key=openrouter_api_key, + api_base=openrouter_api_base, + organization=organization, + project=project, + max_retries=max_retries, + timeout=timeout, + logging_obj=logging_obj, + client=client, + aspeech=aspeech, + shared_session=shared_session, + ) + return openrouter_response if response is None: raise Exception( diff --git a/tests/test_litellm/llms/openrouter/test_openrouter_audio_speech.py b/tests/test_litellm/llms/openrouter/test_openrouter_audio_speech.py new file mode 100644 index 00000000000..23209a1141d --- /dev/null +++ b/tests/test_litellm/llms/openrouter/test_openrouter_audio_speech.py @@ -0,0 +1,129 @@ +"""OpenRouter TTS is an OpenAI-compatible POST /api/v1/audio/speech passthrough.""" + +from __future__ import annotations + +import json +from typing import Final + +import httpx +import pytest +import respx + +import litellm + +OPENROUTER_SPEECH_URL: Final = "https://openrouter.ai/api/v1/audio/speech" +TTS_MODEL: Final = "openrouter/google/gemini-3.1-flash-tts-preview" +UPSTREAM_MODEL: Final = "google/gemini-3.1-flash-tts-preview" + + +@pytest.fixture(autouse=True) +def _flush_openai_client_cache(): + cache = getattr(litellm, "in_memory_llm_clients_cache", None) + if cache is not None: + cache.flush_cache() + yield + if cache is not None: + cache.flush_cache() + + +def test_speech_openrouter_posts_to_openrouter_speech_endpoint( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + audio_bytes: Final = b"pcm-fake-audio" + mock_route: Final = respx_mock.post(OPENROUTER_SPEECH_URL).mock( + return_value=httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/pcm"}) + ) + + response: Final = litellm.speech( + model=TTS_MODEL, + input="Hello world", + voice="Kore", + response_format="pcm", + ) + + assert mock_route.called + request: Final = mock_route.calls.last.request + assert json.loads(request.content) == { + "model": UPSTREAM_MODEL, + "input": "Hello world", + "voice": "Kore", + "response_format": "pcm", + } + assert request.headers["authorization"] == "Bearer sk-or-test" + assert request.headers["http-referer"] == "https://litellm.ai" + assert request.headers["x-title"] == "liteLLM" + assert response.content == audio_bytes + + +def test_speech_openrouter_uses_openrouter_api_base_env(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + monkeypatch.setenv("OPENROUTER_API_BASE", "https://openrouter.env.example/api/v1") + monkeypatch.setattr(litellm, "api_base", None) + audio_bytes: Final = b"env-base-audio" + env_route: Final = respx_mock.post("https://openrouter.env.example/api/v1/audio/speech").mock( + return_value=httpx.Response(200, content=audio_bytes) + ) + + response: Final = litellm.speech( + model=TTS_MODEL, + input="Hello world", + voice="Kore", + ) + + assert env_route.called + assert response.content == audio_bytes + + +def test_speech_openrouter_uses_chat_ranking_header_env(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + monkeypatch.setenv("OR_SITE_URL", "https://example.com") + monkeypatch.setenv("OR_APP_NAME", "MyApp") + mock_route: Final = respx_mock.post(OPENROUTER_SPEECH_URL).mock(return_value=httpx.Response(200, content=b"hdr")) + + litellm.speech(model=TTS_MODEL, input="Hello world", voice="Kore") + + request: Final = mock_route.calls.last.request + assert request.headers["http-referer"] == "https://example.com" + assert request.headers["x-title"] == "MyApp" + + +def test_speech_openrouter_uses_configured_api_base(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + audio_bytes: Final = b"gateway-audio" + gateway_route: Final = respx_mock.post("https://openrouter.gateway.internal/api/v1/audio/speech").mock( + return_value=httpx.Response(200, content=audio_bytes) + ) + + response: Final = litellm.speech( + model=TTS_MODEL, + input="Hello world", + voice="Kore", + api_base="https://openrouter.gateway.internal/api/v1", + ) + + assert gateway_route.called + assert response.content == audio_bytes + + +@pytest.mark.asyncio +async def test_aspeech_openrouter_posts_to_openrouter_speech_endpoint( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + audio_bytes: Final = b"async-pcm-audio" + mock_route: Final = respx_mock.post(OPENROUTER_SPEECH_URL).mock( + return_value=httpx.Response(200, content=audio_bytes) + ) + + response: Final = await litellm.aspeech( + model=TTS_MODEL, + input="Hello world", + voice="Kore", + response_format="pcm", + ) + + assert mock_route.called + assert json.loads(mock_route.calls.last.request.content)["model"] == UPSTREAM_MODEL + assert response.content == audio_bytes