diff --git a/README.md b/README.md index 5fd84b529dc..44038d3e37d 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th | [Fireworks AI (`fireworks_ai`)](https://docs.litellm.ai/docs/providers/fireworks_ai) | ✅ | ✅ | ✅ | | | | | | | | | [FriendliAI (`friendliai`)](https://docs.litellm.ai/docs/providers/friendliai) | ✅ | ✅ | ✅ | | | | | | | | | [Galadriel (`galadriel`)](https://docs.litellm.ai/docs/providers/galadriel) | ✅ | ✅ | ✅ | | | | | | | | +| [Gandr (`gandr`)](https://docs.litellm.ai/docs/providers/gandr) | | | | | | | ✅ | | | | | [GitHub Copilot (`github_copilot`)](https://docs.litellm.ai/docs/providers/github_copilot) | ✅ | ✅ | ✅ | ✅ | | | | | | | | [GitHub Models (`github`)](https://docs.litellm.ai/docs/providers/github) | ✅ | ✅ | ✅ | | | | | | | | | [Google - PaLM](https://docs.litellm.ai/docs/providers/palm) | ✅ | ✅ | ✅ | | | | | | | | diff --git a/litellm/llms/gandr/__init__.py b/litellm/llms/gandr/__init__.py new file mode 100644 index 00000000000..80203345e85 --- /dev/null +++ b/litellm/llms/gandr/__init__.py @@ -0,0 +1,7 @@ +""" +Gandr provider +""" + +from .text_to_speech.transformation import GandrException, GandrTextToSpeechConfig + +__all__ = ("GandrException", "GandrTextToSpeechConfig") diff --git a/litellm/llms/gandr/common_utils.py b/litellm/llms/gandr/common_utils.py new file mode 100644 index 00000000000..8958363c6d4 --- /dev/null +++ b/litellm/llms/gandr/common_utils.py @@ -0,0 +1,5 @@ +from litellm.llms.base_llm.chat.transformation import BaseLLMException + + +class GandrException(BaseLLMException): + pass diff --git a/litellm/llms/gandr/text_to_speech/__init__.py b/litellm/llms/gandr/text_to_speech/__init__.py new file mode 100644 index 00000000000..43af2795540 --- /dev/null +++ b/litellm/llms/gandr/text_to_speech/__init__.py @@ -0,0 +1,7 @@ +""" +Gandr Text-to-Speech module +""" + +from .transformation import GandrException, GandrTextToSpeechConfig + +__all__ = ("GandrException", "GandrTextToSpeechConfig") diff --git a/litellm/llms/gandr/text_to_speech/transformation.py b/litellm/llms/gandr/text_to_speech/transformation.py new file mode 100644 index 00000000000..9621623ff30 --- /dev/null +++ b/litellm/llms/gandr/text_to_speech/transformation.py @@ -0,0 +1,266 @@ +""" +Gandr Text-to-Speech transformation + +Maps OpenAI TTS spec to the Gandr /v1/audio/speech OpenAI-compatible API. + +Gandr is a spoken-audio inference provider. Its HTTP lane accepts the OpenAI +Audio Speech request shape and returns raw audio bytes, so the same +`sample` that works against `tts.gandr.ai/v1/audio/speech` with the official +OpenAI SDK works here unchanged. +""" + +from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeAlias # noqa: TID251 # Any matches the base interface + +import httpx +from httpx import Headers + +import litellm +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.text_to_speech.transformation import ( + BaseTextToSpeechConfig, + TextToSpeechRequestData, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.utils import all_litellm_params + +from ..common_utils import GandrException + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.llms.openai import HttpxBinaryResponseContent +else: + LiteLLMLoggingObj: TypeAlias = Any + HttpxBinaryResponseContent: TypeAlias = Any + + +def _resolve_voice(voice: object) -> str | None: + """Return a voice id from a plain string or a voice-object mapping.""" + if isinstance(voice, str) and voice.strip(): + return voice + if isinstance(voice, dict): + for key in ("voice_id", "id", "name"): + candidate = voice.get(key) + if isinstance(candidate, str) and candidate.strip(): + return candidate + return None + + +def _as_voice_str(value: object) -> str | None: + """Return the value if it is a non-empty string, else None.""" + if isinstance(value, str) and value.strip(): + return value + return None + + +class GandrTextToSpeechConfig(BaseTextToSpeechConfig): + """ + Configuration for Gandr Text-to-Speech. + + The Gandr endpoint is OpenAI-compatible (`POST /v1/audio/speech`), so the + request body is passed through with the OpenAI field names (`voice`, + `response_format`, `speed`) intact. Only authentication differs: calls are + authorized with an `Authorization: Bearer` header using a `gnd_` token. + + Reference: https://gandr.ai/docs + """ + + TTS_BASE_URL = "https://tts.gandr.ai/v1" + TTS_ENDPOINT_PATH = "/audio/speech" + DEFAULT_OUTPUT_FORMAT = "wav" + SUPPORTED_MODES: ClassVar[list[str]] = ["audio_speech"] # mutable-ok: matches the base class attribute type + + #: Gandr's speed range. The engine clamps speed to 0.6-1.5. + SUPPORTED_SPEED_RANGE = (0.6, 1.5) + + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: matches the base interface signature + """ + Gandr TTS supports these OpenAI parameters + """ + return ["voice", "response_format", "speed"] # mutable-ok: matches the base interface signature + + def map_openai_params( + self, + model: str, + optional_params: dict, # mutable-ok: matches the base interface signature + voice: str | dict | None = None, # mutable-ok: matches the base interface signature + drop_params: bool = False, + kwargs: dict[str, Any] | None = None, # mutable-ok: matches the base interface signature + ) -> tuple[str | None, dict]: # mutable-ok: matches the base interface signature + """ + Map OpenAI parameters to Gandr TTS parameters. + + Gandr accepts the OpenAI request body verbatim, so this is effectively + a passthrough. `voice` is returned as-is and the remaining params are + preserved on the body under their OpenAI names, which means an + unmodified client always gets audio back. + """ + params: Final = ( + dict(optional_params) if optional_params else {} # mutable-ok: local working copy, keys popped below + ) + passthrough_kwargs: Final = ( + dict(kwargs) if kwargs is not None else {} # mutable-ok: local working copy, keys popped below + ) + + voice_override: Final = params.pop("voice_id", None) + mapped_voice: Final = _resolve_voice(voice) or _as_voice_str(voice_override) + + if mapped_voice is None: + raise ValueError("Gandr voice is required. Pass `voice` when calling `litellm.speech()`.") + + # `response_format` is passed through under its OpenAI name; the + # endpoint serves mp3 (its default), wav and pcm. + response_format: Final = params.get("response_format") + if response_format is None: + params["response_format"] = self.DEFAULT_OUTPUT_FORMAT + + # `speed` passes through too. Gandr clamps to its own audible range + # server-side, so out-of-range values degrade gracefully. + speed: Final = params.get("speed") + if speed is not None: + try: + float(speed) + except (TypeError, ValueError): + params.pop("speed", None) + + mapped_params: Final[dict[str, Any]] = { # mutable-ok: returned per the base contract + k: v for k, v in params.items() if v is not None + } + + reserved_kwarg_keys: Final = frozenset(all_litellm_params) | frozenset( + ( + "voice", + "model", + "response_format", + "output_format", + "extra_body", + "user", + ) + ) + for key in tuple(passthrough_kwargs): + if key in reserved_kwarg_keys: + continue + value = passthrough_kwargs[key] + if value is None: + continue + mapped_params[key] = value + + return mapped_voice, mapped_params + + def validate_environment( + self, + headers: dict, # mutable-ok: matches the base interface signature + model: str, + api_key: str | None = None, + api_base: str | None = None, + ) -> dict: # mutable-ok: matches the base interface signature + """ + Validate the Gandr environment and set up authentication headers. + + A caller-supplied api_base is honored when constructing the request + URL, so falling back to a server-managed key while the caller controls + the host would leak that key. The provider default and the operator's + own GANDR_API_BASE override are the only trusted destinations for a + server-managed key; anything else requires an explicit api_key. + """ + server_api_key: Final = litellm.api_key or get_secret_str("GANDR_API_KEY") + + if api_key is None and api_base is not None and server_api_key is not None: + trusted_base: Final = (get_secret_str("GANDR_API_BASE") or self.TTS_BASE_URL).rstrip("/") + if api_base.rstrip("/") != trusted_base: + raise ValueError( + "Refusing to send the server-configured GANDR_API_KEY to the " + f"caller-supplied api_base '{api_base}'. Pass an explicit " + "api_key when overriding api_base." + ) + + resolved_api_key: Final = api_key or server_api_key + + if resolved_api_key is None: + raise ValueError( + "Gandr API key is required. Set GANDR_API_KEY environment variable " + "or pass `api_key` to `litellm.speech()`." + ) + + headers.update( + { # mutable-ok: field type is dict upstream + "Authorization": f"Bearer {resolved_api_key}", + "Content-Type": "application/json", + } + ) + + return headers + + def get_error_class( + self, error_message: str, status_code: int, headers: dict | Headers + ) -> BaseLLMException: # mutable-ok: matches the base interface signature + return GandrException(message=error_message, status_code=status_code, headers=headers) + + def transform_text_to_speech_request( + self, + model: str, + input: str, + voice: str | None, + optional_params: dict, # mutable-ok: matches the base interface signature + litellm_params: dict, # mutable-ok: matches the base interface signature + headers: dict, # mutable-ok: matches the base interface signature + ) -> TextToSpeechRequestData: + """ + Build the Gandr TTS request payload. The body is OpenAI-shaped + (`input`, `model`, `voice`, `response_format`, `speed`); the repo's + HTTP handler attaches the required `Authorization: Bearer` header from + `validate_environment`. + """ + params: Final = ( + dict(optional_params) if optional_params else {} # mutable-ok: local working copy, keys popped below + ) + extra_body: Final = params.pop("extra_body", None) + + request_body: Final[dict[str, Any]] = { # mutable-ok: JSON request body consumed by the HTTP handler + "input": input, + "model": model, + "voice": voice, + "response_format": params.get("response_format", self.DEFAULT_OUTPUT_FORMAT), + "speed": params.get("speed", 1.0), + } + + for key, value in params.items(): + if value is None or key in ("response_format", "speed", "voice"): + continue + request_body[key] = value + + if isinstance(extra_body, dict): + for key, value in extra_body.items(): + if value is None: + continue + request_body[key] = value + + return TextToSpeechRequestData( + dict_body=request_body, + headers={"Content-Type": "application/json"}, # mutable-ok: field type is dict upstream + ) + + def transform_text_to_speech_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> "HttpxBinaryResponseContent": + """ + Wrap Gandr binary audio response. + """ + from litellm.types.llms.openai import HttpxBinaryResponseContent + + return HttpxBinaryResponseContent(raw_response) + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: dict, # mutable-ok: matches the base interface signature + ) -> str: + """ + Construct the Gandr endpoint URL. + """ + base_url: Final = (api_base or get_secret_str("GANDR_API_BASE") or self.TTS_BASE_URL).rstrip("/") + + return f"{base_url}{self.TTS_ENDPOINT_PATH}" diff --git a/litellm/main.py b/litellm/main.py index 27700cf9def..f18c21a8df4 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8830,6 +8830,45 @@ def speech( client=client, _is_async=aspeech or False, ) + elif custom_llm_provider == "gandr": + from litellm.llms.gandr.text_to_speech.transformation import ( + GandrTextToSpeechConfig, + ) + + if text_to_speech_provider_config is None: # rebind-ok: default construction when unset + text_to_speech_provider_config = GandrTextToSpeechConfig() # rebind-ok: default construction when unset + + assert isinstance(text_to_speech_provider_config, GandrTextToSpeechConfig) + gandr_config: Final = text_to_speech_provider_config + + voice_input: Final = voice if isinstance(voice, str) else None + if voice_input is None or not voice_input.strip(): + raise litellm.BadRequestError( + message="'voice' must resolve to a Gandr voice id for Gandr TTS", + model=model, + llm_provider=custom_llm_provider, + ) + gandr_voice_id: Final = voice_input.strip() + + if api_base is not None: + litellm_params_dict["api_base"] = api_base + if api_key is not None: + litellm_params_dict["api_key"] = api_key + + response = base_llm_http_handler.text_to_speech_handler( # rebind-ok: every provider branch assigns response for the shared return + model=model, + input=input, + voice=gandr_voice_id, + text_to_speech_provider_config=gandr_config, + text_to_speech_optional_params=optional_params, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params_dict, + logging_obj=logging_obj, + timeout=timeout, + extra_headers=extra_headers, + client=client, + _is_async=aspeech or False, + ) elif custom_llm_provider == "vertex_ai" or custom_llm_provider == "vertex_ai_beta": from litellm.llms.vertex_ai.text_to_speech.transformation import ( VertexAILyriaTextToSpeechConfig, diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 4bec42a7868..282dcc09324 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -1617,6 +1617,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "Gandr", + "provider_display_name": "Gandr", + "litellm_provider": "gandr", + "credential_fields": [ + { + "key": "api_key", + "label": "API Key", + "placeholder": "gnd_...", + "tooltip": "Gandr API key from https://gandr.ai", + "required": true, + "field_type": "password", + "options": null, + "default_value": null + }, + { + "key": "api_base", + "label": "API Base URL", + "placeholder": "https://tts.gandr.ai/v1", + "tooltip": "Leave at the default unless you route Gandr through your own proxy", + "required": false, + "field_type": "text", + "options": null, + "default_value": "https://tts.gandr.ai/v1" + } + ], + "default_model_placeholder": "gandr/mia" + }, { "provider": "GIGACHAT", "provider_display_name": "GigaChat", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5939a350970..cd671288218 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4489,6 +4489,7 @@ class LlmProviders(str, Enum): INFINITY = "infinity" DEEPGRAM = "deepgram" ELEVENLABS = "elevenlabs" + GANDR = "gandr" NOVITA = "novita" AIOHTTP_OPENAI = "aiohttp_openai" LANGFUSE = "langfuse" diff --git a/litellm/utils.py b/litellm/utils.py index 7de7ce6188d..b02739bca59 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -10754,6 +10754,12 @@ class ProviderConfigManager: ) return ElevenLabsTextToSpeechConfig() + elif litellm.LlmProviders.GANDR == provider: + from litellm.llms.gandr.text_to_speech.transformation import ( + GandrTextToSpeechConfig, + ) + + return GandrTextToSpeechConfig() elif litellm.LlmProviders.RUNWAYML == provider: from litellm.llms.runwayml.text_to_speech.transformation import ( RunwayMLTextToSpeechConfig, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index d65da3f41fd..e5743dcad55 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1191,6 +1191,37 @@ "interactions": true } }, + "gandr": { + "display_name": "Gandr (`gandr`)", + "url": "https://docs.litellm.ai/docs/providers/gandr", + "endpoints": { + "chat_completions": false, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": true, + "moderations": false, + "batches": false, + "rerank": false, + "container": false, + "compact": false, + "a2a": false, + "interactions": false, + "vector_store_files": false, + "vector_stores_create": false, + "vector_stores_search": false, + "assistants": false, + "container_files": false, + "fine_tuning": false, + "image_variations": false, + "rag_ingest": false, + "rag_query": false, + "realtime": false, + "text_completion": false + } + }, "gdc": { "display_name": "Google Distributed Cloud (GDC)", "url": "https://docs.litellm.ai/docs/providers/gdc", diff --git a/tests/test_litellm/llms/gandr/test_gandr_speech_route.py b/tests/test_litellm/llms/gandr/test_gandr_speech_route.py new file mode 100644 index 00000000000..c9e5e3c7ef0 --- /dev/null +++ b/tests/test_litellm/llms/gandr/test_gandr_speech_route.py @@ -0,0 +1,262 @@ +""" +Route and edge coverage for the Gandr text to speech provider. + +The transformation tests next to this file cover the happy paths. These +tests reach the remaining branches: voice objects, kwargs passthrough, +server-managed keys, the error class, extra_body merging, the response +wrapper, the provider config registry and the litellm.speech() route with +the HTTP handler mocked. +""" + +import json +from unittest.mock import MagicMock + +import httpx +import pytest +import respx + +import litellm +from litellm import speech +from litellm.llms.gandr.common_utils import GandrException +from litellm.llms.gandr.text_to_speech.transformation import ( + GandrTextToSpeechConfig, + _as_voice_str, + _resolve_voice, +) +from litellm.types.llms.openai import HttpxBinaryResponseContent +from litellm.utils import ProviderConfigManager + + +def test_resolve_voice_accepts_strings_and_voice_objects(): + assert _resolve_voice("gandr-mia") == "gandr-mia" + assert _resolve_voice(" ") is None + assert _resolve_voice({"voice_id": "gandr-ava"}) == "gandr-ava" + assert _resolve_voice({"id": "gandr-leo"}) == "gandr-leo" + assert _resolve_voice({"name": "gandr-dane"}) == "gandr-dane" + assert _resolve_voice({"voice_id": "", "name": "gandr-jenny"}) == "gandr-jenny" + assert _resolve_voice({"other": "x"}) is None + assert _resolve_voice(42) is None + + +def test_as_voice_str(): + assert _as_voice_str("gandr-mia") == "gandr-mia" + assert _as_voice_str("") is None + assert _as_voice_str(None) is None + assert _as_voice_str(7) is None + + +def test_map_openai_params_voice_object_and_voice_id_fallback(): + config = GandrTextToSpeechConfig() + + voice, mapped = config.map_openai_params( + model="gandr/mia", + optional_params={"voice_id": "gandr-lewis"}, + voice={"voice_id": "gandr-ava"}, + ) + assert voice == "gandr-ava" + assert "voice_id" not in mapped + + voice, mapped = config.map_openai_params( + model="gandr/mia", + optional_params={"voice_id": "gandr-lewis"}, + voice=None, + ) + assert voice == "gandr-lewis" + assert mapped["response_format"] == "wav" + + +def test_map_openai_params_drops_unparseable_speed_and_passes_kwargs(): + config = GandrTextToSpeechConfig() + + voice, mapped = config.map_openai_params( + model="gandr/mia", + optional_params={"speed": "fast", "response_format": "mp3", "unused": None}, + voice="gandr-mia", + kwargs={ + "temperature": 0.4, + "model": "should-be-ignored", + "user": "should-be-ignored", + "extra_body": {"ignored": True}, + "metadata": {"ignored": True}, + "empty": None, + }, + ) + + assert voice == "gandr-mia" + assert "speed" not in mapped + assert "unused" not in mapped + assert mapped["response_format"] == "mp3" + assert mapped["temperature"] == 0.4 + for reserved in ("model", "user", "extra_body", "metadata", "empty"): + assert reserved not in mapped + + +def test_validate_environment_uses_server_key_for_default_base(monkeypatch): + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setenv("GANDR_API_KEY", "gnd_server_secret") + monkeypatch.delenv("GANDR_API_BASE", raising=False) + config = GandrTextToSpeechConfig() + + headers = config.validate_environment(headers={}, model="gandr/mia") + assert headers["Authorization"] == "Bearer gnd_server_secret" + assert headers["Content-Type"] == "application/json" + + headers = config.validate_environment( + headers={}, model="gandr/mia", api_base="https://tts.gandr.ai/v1/" + ) + assert headers["Authorization"] == "Bearer gnd_server_secret" + + +def test_validate_environment_trusts_operator_api_base(monkeypatch): + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setenv("GANDR_API_KEY", "gnd_server_secret") + monkeypatch.setenv("GANDR_API_BASE", "https://tts.internal.example/v1") + config = GandrTextToSpeechConfig() + + headers = config.validate_environment( + headers={}, model="gandr/mia", api_base="https://tts.internal.example/v1" + ) + assert headers["Authorization"] == "Bearer gnd_server_secret" + + with pytest.raises(ValueError, match="caller-supplied api_base"): + config.validate_environment( + headers={}, model="gandr/mia", api_base="https://elsewhere.example/v1" + ) + + +def test_validate_environment_requires_some_key(monkeypatch): + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.delenv("GANDR_API_KEY", raising=False) + config = GandrTextToSpeechConfig() + + with pytest.raises(ValueError, match="Gandr API key is required"): + config.validate_environment(headers={}, model="gandr/mia") + + +def test_get_error_class_returns_gandr_exception(): + config = GandrTextToSpeechConfig() + + error = config.get_error_class( + error_message="quota exhausted", status_code=402, headers={"x-test": "1"} + ) + + assert isinstance(error, GandrException) + assert error.status_code == 402 + assert "quota exhausted" in str(error) + + +def test_transform_request_merges_extra_body_and_skips_none(): + config = GandrTextToSpeechConfig() + + data = config.transform_text_to_speech_request( + model="tts-1", + input="Order 4471 is ready", + voice="gandr-mia", + optional_params={ + "temperature": 0.3, + "dropped": None, + "extra_body": {"cfg": 0.6, "ignored": None}, + }, + litellm_params={}, + headers={}, + ) + + body = data["dict_body"] + assert body["response_format"] == "wav" + assert body["speed"] == 1.0 + assert body["temperature"] == 0.3 + assert body["cfg"] == 0.6 + assert "dropped" not in body + assert "ignored" not in body + assert "extra_body" not in body + + +def test_transform_response_wraps_binary_content(): + config = GandrTextToSpeechConfig() + raw = httpx.Response(200, content=b"RIFF....WAVE", headers={"Content-Type": "audio/wav"}) + + wrapped = config.transform_text_to_speech_response( + model="gandr/mia", raw_response=raw, logging_obj=MagicMock() + ) + + assert isinstance(wrapped, HttpxBinaryResponseContent) + assert wrapped.content == b"RIFF....WAVE" + + +def test_get_complete_url_honours_operator_base(monkeypatch): + monkeypatch.setenv("GANDR_API_BASE", "https://tts.internal.example/v1/") + config = GandrTextToSpeechConfig() + + assert ( + config.get_complete_url(model="gandr/mia", api_base=None, litellm_params={}) + == "https://tts.internal.example/v1/audio/speech" + ) + + +def test_provider_config_registry_returns_gandr_config(): + config = ProviderConfigManager.get_provider_text_to_speech_config( + model="gandr/mia", provider=litellm.LlmProviders.GANDR + ) + + assert isinstance(config, GandrTextToSpeechConfig) + assert litellm.LlmProviders("gandr") is litellm.LlmProviders.GANDR + + +def test_speech_routes_to_gandr_endpoint(respx_mock: respx.MockRouter): + route = respx_mock.post("https://tts.gandr.ai/v1/audio/speech").mock( + return_value=httpx.Response( + 200, content=b"fake wav bytes", headers={"Content-Type": "audio/wav"} + ) + ) + + response = speech( + model="gandr/mia", + voice="gandr-mia", + input="Your order number is 4471.", + api_key="gnd_test_key", + response_format="wav", + ) + + assert route.called + request = route.calls.last.request + assert request.headers["Authorization"] == "Bearer gnd_test_key" + assert request.headers["Content-Type"] == "application/json" + body = json.loads(request.content) + assert body == { + "input": "Your order number is 4471.", + "model": "mia", + "voice": "gandr-mia", + "response_format": "wav", + "speed": 1.0, + } + assert response.content == b"fake wav bytes" + + +def test_speech_honours_caller_api_base_with_explicit_key(respx_mock: respx.MockRouter): + route = respx_mock.post("https://staging.example.com/v1/audio/speech").mock( + return_value=httpx.Response(200, content=b"pcm", headers={"Content-Type": "audio/pcm"}) + ) + + response = speech( + model="gandr/mia", + voice="gandr-ava", + input="Hello.", + api_key="gnd_caller_key", + api_base="https://staging.example.com/v1", + response_format="pcm", + ) + + assert route.called + assert route.calls.last.request.headers["Authorization"] == "Bearer gnd_caller_key" + assert json.loads(route.calls.last.request.content)["response_format"] == "pcm" + assert response.content == b"pcm" + + +def test_speech_gandr_requires_a_voice(respx_mock: respx.MockRouter): + route = respx_mock.post("https://tts.gandr.ai/v1/audio/speech") + + # The litellm client wrapper may re-raise inside its own exception + # types; the message is the stable contract. + with pytest.raises(Exception, match="Gandr voice is required"): + speech(model="gandr/mia", input="No voice given", api_key="gnd_test_key") + assert not route.called diff --git a/tests/test_litellm/llms/gandr/test_gandr_text_to_speech_transformation.py b/tests/test_litellm/llms/gandr/test_gandr_text_to_speech_transformation.py new file mode 100644 index 00000000000..fde9c04095c --- /dev/null +++ b/tests/test_litellm/llms/gandr/test_gandr_text_to_speech_transformation.py @@ -0,0 +1,127 @@ +import pytest + +from litellm.llms.gandr.text_to_speech.transformation import ( + GandrTextToSpeechConfig, +) + + +def test_gandr_default_url(): + config = GandrTextToSpeechConfig() + + url = config.get_complete_url( + model="gandr/mia", + api_base=None, + litellm_params={}, + ) + + assert url == "https://tts.gandr.ai/v1/audio/speech" + + +def test_gandr_custom_api_base(): + config = GandrTextToSpeechConfig() + + url = config.get_complete_url( + model="gandr/mia", + api_base="https://tts.gandr.ai/v1", + litellm_params={}, + ) + + assert url == "https://tts.gandr.ai/v1/audio/speech" + + +def test_gandr_untrusted_api_base_rejects_server_key(monkeypatch): + monkeypatch.setenv("GANDR_API_KEY", "gnd_server_secret") + config = GandrTextToSpeechConfig() + + with pytest.raises(ValueError, match="caller-supplied api_base"): + config.validate_environment( + headers={}, + model="gandr/mia", + api_key=None, + api_base="https://attacker.example.com/v1", + ) + + +def test_gandr_custom_api_base_with_explicit_key(monkeypatch): + monkeypatch.setenv("GANDR_API_KEY", "gnd_server_secret") + config = GandrTextToSpeechConfig() + + headers = config.validate_environment( + headers={}, + model="gandr/mia", + api_key="gnd_caller_key", + api_base="https://staging.example.com/v1", + ) + + assert headers["Authorization"] == "Bearer gnd_caller_key" + + +def test_gandr_supported_openai_params(): + config = GandrTextToSpeechConfig() + + assert config.get_supported_openai_params(model="gandr/mia") == [ + "voice", + "response_format", + "speed", + ] + + +def test_gandr_map_openai_params_default_format(): + config = GandrTextToSpeechConfig() + + voice, mapped = config.map_openai_params( + model="gandr/mia", + optional_params={"speed": 1.1}, + voice="alloy", + ) + + assert voice == "alloy" + assert mapped["response_format"] == "wav" + assert mapped["speed"] == 1.1 + + +def test_gandr_map_openai_params_passthrough(): + config = GandrTextToSpeechConfig() + + voice, mapped = config.map_openai_params( + model="gandr/mia", + optional_params={"response_format": "pcm", "speed": 0.8}, + voice="gandr-dane", + ) + + assert voice == "gandr-dane" + assert mapped["response_format"] == "pcm" + assert mapped["speed"] == 0.8 + + +def test_gandr_map_openai_params_requires_voice(): + config = GandrTextToSpeechConfig() + + with pytest.raises(ValueError, match="Gandr voice is required"): + config.map_openai_params( + model="gandr/mia", + optional_params={}, + voice=None, + ) + + +def test_gandr_transform_request_body(): + config = GandrTextToSpeechConfig() + + data = config.transform_text_to_speech_request( + model="tts-1", + input="Hello from Gandr", + voice="alloy", + optional_params={"response_format": "wav", "speed": 1.0}, + litellm_params={}, + headers={}, + ) + + assert data["dict_body"] == { + "input": "Hello from Gandr", + "model": "tts-1", + "voice": "alloy", + "response_format": "wav", + "speed": 1.0, + } + assert data["headers"]["Content-Type"] == "application/json"