Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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.
Expand Down
137 changes: 136 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"}
Expand Down
167 changes: 167 additions & 0 deletions test_speech.py
Original file line number Diff line number Diff line change
@@ -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()