From 83bb5131db6a48e40808b87e430b5b1d567a29a3 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:47:34 +0800 Subject: [PATCH] feat: add MiniMax speech synthesis endpoint --- .env.example | 6 ++ README.md | 13 ++++ server.py | 137 +++++++++++++++++++++++++++++++++++++++- test_speech.py | 167 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 test_speech.py diff --git a/.env.example b/.env.example index f61e441b..c5c97c94 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,12 @@ ANTHROPIC_API_KEY="your-anthropic-api-key" # Needed if proxying *to* Anthropic OPENAI_API_KEY="sk-..." GEMINI_API_KEY="your-google-ai-studio-key" +MINIMAX_API_KEY="your-minimax-api-key" + +# Optional: MiniMax speech endpoint. +# Defaults to the global endpoint; use the second URL for the China region. +# MINIMAX_SPEECH_URL="https://api.minimax.io/v1/t2a_v2" +# MINIMAX_SPEECH_URL="https://api.minimaxi.com/v1/t2a_v2" # Optional: Provider Preference and Model Mapping # Controls which provider (google, openai, or anthropic) is preferred for mapping haiku/sonnet. diff --git a/README.md b/README.md index d9ae04c8..4362be2e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ A proxy server that lets you use Anthropic clients with Gemini, OpenAI, or Anthr * `ANTHROPIC_API_KEY`: (Optional) Needed only if proxying *to* Anthropic models. * `OPENAI_API_KEY`: Your OpenAI API key (Required if using the default OpenAI preference or as fallback). * `GEMINI_API_KEY`: Your Google AI Studio (Gemini) API key (Required if `PREFERRED_PROVIDER=google` and `USE_VERTEX_AUTH=true`). + * `MINIMAX_API_KEY`: Your MiniMax API key (Required for speech synthesis). + * `MINIMAX_SPEECH_URL` (Optional): The MiniMax speech endpoint. It defaults to `https://api.minimax.io/v1/t2a_v2`; use `https://api.minimaxi.com/v1/t2a_v2` for the China region. * `USE_VERTEX_AUTH` (Optional): Set to `true` to use Application Default Credentials (ADC) will be used (no static API key required). Note: when USE_VERTEX_AUTH=true, you must configure `VERTEX_PROJECT` and `VERTEX_LOCATION`. * `VERTEX_PROJECT` (Optional): Your Google Cloud Project ID (Required if `PREFERRED_PROVIDER=google` and `USE_VERTEX_AUTH=true`). * `VERTEX_LOCATION` (Optional): The Google Cloud region for Vertex AI (e.g., `us-central1`) (Required if `PREFERRED_PROVIDER=google` and `USE_VERTEX_AUTH=true`). @@ -60,6 +62,17 @@ A proxy server that lets you use Anthropic clients with Gemini, OpenAI, or Anthr ``` *(`--reload` is optional, for development)* +### Speech synthesis + +`POST /v1/audio/speech` accepts MiniMax speech models and forwards the supported HTTP speech fields. Hex responses are decoded into audio bytes; URL responses remain JSON. Set `stream` to `true` to receive decoded audio chunks. + +```bash +curl http://localhost:8082/v1/audio/speech \ + -H 'Content-Type: application/json' \ + --data '{"model":"speech-2.8-hd","text":"Hello","voice_setting":{"voice_id":"English_expressive_narrator"},"audio_setting":{"format":"mp3"}}' \ + --output speech.mp3 +``` + #### Docker If using docker, download the example environment file to `.env` and edit it as described above. diff --git a/server.py b/server.py index 15dacf35..38b06a2f 100644 --- a/server.py +++ b/server.py @@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional, Union, Literal import httpx import os -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, Response, StreamingResponse import litellm import uuid import time @@ -100,6 +100,12 @@ def format(self, record): # Get OpenAI base URL from environment (if set) OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL") +# MiniMax synchronous speech synthesis configuration +MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY") +MINIMAX_SPEECH_URL = os.environ.get( + "MINIMAX_SPEECH_URL", "https://api.minimax.io/v1/t2a_v2" +) + # Get preferred provider (default to openai) PREFERRED_PROVIDER = os.environ.get("PREFERRED_PROVIDER", "openai").lower() @@ -398,6 +404,91 @@ class MessagesResponse(BaseModel): usage: Usage +class SpeechRequest(BaseModel): + model: Literal[ + "speech-2.8-hd", + "speech-2.8-turbo", + "speech-2.6-hd", + "speech-2.6-turbo", + "speech-02-hd", + "speech-02-turbo", + "speech-01-hd", + "speech-01-turbo", + ] = "speech-2.8-hd" + text: str = Field(min_length=1, max_length=10000) + stream: bool = False + language_boost: Optional[str] = None + output_format: Literal["hex", "url"] = "hex" + voice_setting: Optional[Dict[str, Any]] = None + pronunciation_dict: Optional[Dict[str, Any]] = None + audio_setting: Optional[Dict[str, Any]] = None + voice_modify: Optional[Dict[str, Any]] = None + subtitle_enable: Optional[bool] = None + + +SPEECH_MEDIA_TYPES = { + "mp3": "audio/mpeg", + "wav": "audio/wav", + "flac": "audio/flac", + "pcm": "application/octet-stream", +} + + +def speech_media_type(request: SpeechRequest) -> str: + audio_format = (request.audio_setting or {}).get("format", "mp3") + return SPEECH_MEDIA_TYPES.get(audio_format, "application/octet-stream") + + +def check_speech_response(payload: Dict[str, Any], require_complete: bool) -> str: + base_response = payload.get("base_resp") or {} + if base_response.get("status_code") != 0: + message = base_response.get("status_msg") or "Speech synthesis failed" + raise HTTPException(status_code=502, detail=message) + + data = payload.get("data") + if not isinstance(data, dict): + raise HTTPException(status_code=502, detail="Speech response has no data") + if require_complete and data.get("status") != 2: + raise HTTPException(status_code=502, detail="Speech synthesis did not complete") + + audio = data.get("audio") + if not isinstance(audio, str) or not audio: + raise HTTPException(status_code=502, detail="Speech response has no audio") + return audio + + +async def stream_speech_audio(payload: Dict[str, Any]): + headers = {"Authorization": f"Bearer {MINIMAX_API_KEY}"} + try: + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream( + "POST", MINIMAX_SPEECH_URL, headers=headers, json=payload + ) as response: + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=f"Speech service returned HTTP {response.status_code}", + ) + async for line in response.aiter_lines(): + line = line.strip() + if not line: + continue + if line.startswith("data:"): + line = line[5:].strip() + if line == "[DONE]": + break + try: + chunk = json.loads(line) + audio = check_speech_response(chunk, require_complete=False) + yield bytes.fromhex(audio) + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException( + status_code=502, detail="Speech stream contained invalid audio" + ) from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="Speech service request failed") from exc + + @app.middleware("http") async def log_requests(request: Request, call_next): # Get request details @@ -1642,6 +1733,50 @@ async def count_tokens(request: TokenCountRequest, raw_request: Request): raise HTTPException(status_code=500, detail=f"Error counting tokens: {str(e)}") +@app.post("/v1/audio/speech") +async def create_speech(request: SpeechRequest): + if not MINIMAX_API_KEY: + raise HTTPException(status_code=500, detail="MINIMAX_API_KEY is not configured") + if request.stream and request.output_format != "hex": + raise HTTPException( + status_code=400, detail="Streaming speech requires hex output" + ) + + payload = request.model_dump(exclude_none=True) + if request.stream: + return StreamingResponse( + stream_speech_audio(payload), media_type=speech_media_type(request) + ) + + headers = {"Authorization": f"Bearer {MINIMAX_API_KEY}"} + try: + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + MINIMAX_SPEECH_URL, headers=headers, json=payload + ) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="Speech service request failed") from exc + + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=f"Speech service returned HTTP {response.status_code}", + ) + try: + response_payload = response.json() + except ValueError as exc: + raise HTTPException(status_code=502, detail="Speech service returned invalid JSON") from exc + + audio = check_speech_response(response_payload, require_complete=True) + if request.output_format == "url": + return JSONResponse(content=response_payload) + try: + audio_bytes = bytes.fromhex(audio) + except ValueError as exc: + raise HTTPException(status_code=502, detail="Speech response has invalid audio") from exc + return Response(content=audio_bytes, media_type=speech_media_type(request)) + + @app.get("/") async def root(): return {"message": "Anthropic Proxy for LiteLLM"} diff --git a/test_speech.py b/test_speech.py new file mode 100644 index 00000000..197e9797 --- /dev/null +++ b/test_speech.py @@ -0,0 +1,167 @@ +import json +import unittest +from unittest.mock import patch + +from fastapi import HTTPException + +import server + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self.payload = payload + self.status_code = status_code + + def json(self): + return self.payload + + +class FakeStreamResponse(FakeResponse): + async def aiter_lines(self): + yield 'data: {"data":{"audio":"4944","status":1},"base_resp":{"status_code":0}}' + yield 'data: {"data":{"audio":"33","status":2},"base_resp":{"status_code":0}}' + yield "data: [DONE]" + + +class FakeStreamContext: + def __init__(self, response): + self.response = response + + async def __aenter__(self): + return self.response + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +class FakeAsyncClient: + response = None + last_request = None + + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + async def post(self, url, headers, json): + type(self).last_request = (url, headers, json) + return type(self).response + + def stream(self, method, url, headers, json): + type(self).last_request = (url, headers, json) + return FakeStreamContext(type(self).response) + + +class SpeechRouteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.success_payload = { + "data": {"audio": "494433", "status": 2}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + } + FakeAsyncClient.response = FakeResponse(self.success_payload) + FakeAsyncClient.last_request = None + + async def test_decodes_hex_audio_and_forwards_supported_fields(self): + request = server.SpeechRequest( + model="speech-2.8-hd", + text="Hello", + language_boost="English", + voice_setting={"voice_id": "English_expressive_narrator"}, + pronunciation_dict={"tone": ["Hello/Hello"]}, + audio_setting={"format": "mp3", "sample_rate": 32000}, + voice_modify={"pitch": 1}, + subtitle_enable=True, + ) + with ( + patch.object(server, "MINIMAX_API_KEY", "test-key"), + patch.object(server, "httpx") as mock_httpx, + ): + mock_httpx.AsyncClient = FakeAsyncClient + response = await server.create_speech(request) + + self.assertEqual(response.body, b"ID3") + self.assertEqual(response.media_type, "audio/mpeg") + url, headers, payload = FakeAsyncClient.last_request + self.assertEqual(url, "https://api.minimax.io/v1/t2a_v2") + self.assertEqual(headers, {"Authorization": "Bearer test-key"}) + self.assertEqual(payload["model"], "speech-2.8-hd") + self.assertEqual( + payload["voice_setting"]["voice_id"], "English_expressive_narrator" + ) + self.assertEqual(payload["audio_setting"]["format"], "mp3") + self.assertTrue(payload["subtitle_enable"]) + + async def test_uses_configured_regional_endpoint(self): + request = server.SpeechRequest(text="Hello") + with ( + patch.object(server, "MINIMAX_API_KEY", "test-key"), + patch.object( + server, + "MINIMAX_SPEECH_URL", + "https://api.minimaxi.com/v1/t2a_v2", + ), + patch.object(server.httpx, "AsyncClient", FakeAsyncClient), + ): + await server.create_speech(request) + + url, _, _ = FakeAsyncClient.last_request + self.assertEqual(url, "https://api.minimaxi.com/v1/t2a_v2") + + async def test_url_output_preserves_response_json(self): + url_payload = { + "data": {"audio": "https://example.invalid/speech.mp3", "status": 2}, + "base_resp": {"status_code": 0}, + } + FakeAsyncClient.response = FakeResponse(url_payload) + request = server.SpeechRequest(text="Hello", output_format="url") + with ( + patch.object(server, "MINIMAX_API_KEY", "test-key"), + patch.object(server.httpx, "AsyncClient", FakeAsyncClient), + ): + response = await server.create_speech(request) + + self.assertEqual(json.loads(response.body), url_payload) + + async def test_stream_decodes_audio_chunks(self): + FakeAsyncClient.response = FakeStreamResponse({}) + request = server.SpeechRequest(text="Hello", stream=True) + with ( + patch.object(server, "MINIMAX_API_KEY", "test-key"), + patch.object(server.httpx, "AsyncClient", FakeAsyncClient), + ): + response = await server.create_speech(request) + chunks = [chunk async for chunk in response.body_iterator] + + self.assertEqual(chunks, [b"ID", b"3"]) + + async def test_rejects_url_output_for_streaming(self): + request = server.SpeechRequest(text="Hello", stream=True, output_format="url") + with patch.object(server, "MINIMAX_API_KEY", "test-key"): + with self.assertRaises(HTTPException) as context: + await server.create_speech(request) + self.assertEqual(context.exception.status_code, 400) + + async def test_surfaces_api_error(self): + FakeAsyncClient.response = FakeResponse( + { + "data": None, + "base_resp": {"status_code": 1001, "status_msg": "Invalid request"}, + } + ) + request = server.SpeechRequest(text="Hello") + with ( + patch.object(server, "MINIMAX_API_KEY", "test-key"), + patch.object(server.httpx, "AsyncClient", FakeAsyncClient), + ): + with self.assertRaises(HTTPException) as context: + await server.create_speech(request) + self.assertEqual(context.exception.status_code, 502) + self.assertEqual(context.exception.detail, "Invalid request") + + +if __name__ == "__main__": + unittest.main()