This commit is contained in:
Sam Alghaithi 2026-10-21 14:33:24 +00:00 • committed by GitHub
commit 7831bb892f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 780 additions and 0 deletions

View file

@ -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) | ✅ | ✅ | ✅ | | | | | | | |

View file

@ -0,0 +1,7 @@
"""
Gandr provider
"""
from .text_to_speech.transformation import GandrException, GandrTextToSpeechConfig
__all__ = ("GandrException", "GandrTextToSpeechConfig")

View file

@ -0,0 +1,5 @@
from litellm.llms.base_llm.chat.transformation import BaseLLMException
class GandrException(BaseLLMException):
pass

View file

@ -0,0 +1,7 @@
"""
Gandr Text-to-Speech module
"""
from .transformation import GandrException, GandrTextToSpeechConfig
__all__ = ("GandrException", "GandrTextToSpeechConfig")

View file

@ -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}"

View file

@ -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,

View file

@ -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",

View file

@ -4489,6 +4489,7 @@ class LlmProviders(str, Enum):
INFINITY = "infinity"
DEEPGRAM = "deepgram"
ELEVENLABS = "elevenlabs"
GANDR = "gandr"
NOVITA = "novita"
AIOHTTP_OPENAI = "aiohttp_openai"
LANGFUSE = "langfuse"

View file

@ -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,

View file

@ -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",

View file

@ -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

View file

@ -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"