mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
Merge 3ada814b6d into b6f91a221b
This commit is contained in:
commit
7831bb892f
12 changed files with 780 additions and 0 deletions
|
|
@ -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) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
|
|
|
|||
7
litellm/llms/gandr/__init__.py
Normal file
7
litellm/llms/gandr/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Gandr provider
|
||||
"""
|
||||
|
||||
from .text_to_speech.transformation import GandrException, GandrTextToSpeechConfig
|
||||
|
||||
__all__ = ("GandrException", "GandrTextToSpeechConfig")
|
||||
5
litellm/llms/gandr/common_utils.py
Normal file
5
litellm/llms/gandr/common_utils.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
|
||||
class GandrException(BaseLLMException):
|
||||
pass
|
||||
7
litellm/llms/gandr/text_to_speech/__init__.py
Normal file
7
litellm/llms/gandr/text_to_speech/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Gandr Text-to-Speech module
|
||||
"""
|
||||
|
||||
from .transformation import GandrException, GandrTextToSpeechConfig
|
||||
|
||||
__all__ = ("GandrException", "GandrTextToSpeechConfig")
|
||||
266
litellm/llms/gandr/text_to_speech/transformation.py
Normal file
266
litellm/llms/gandr/text_to_speech/transformation.py
Normal 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}"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4489,6 +4489,7 @@ class LlmProviders(str, Enum):
|
|||
INFINITY = "infinity"
|
||||
DEEPGRAM = "deepgram"
|
||||
ELEVENLABS = "elevenlabs"
|
||||
GANDR = "gandr"
|
||||
NOVITA = "novita"
|
||||
AIOHTTP_OPENAI = "aiohttp_openai"
|
||||
LANGFUSE = "langfuse"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
262
tests/test_litellm/llms/gandr/test_gandr_speech_route.py
Normal file
262
tests/test_litellm/llms/gandr/test_gandr_speech_route.py
Normal 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
|
||||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue