fix(tts): bind MiniMax credentials to their region endpoint

This commit is contained in:
3ASiC 2026-07-16 17:38:44 +08:00 committed by Teknium
parent 7b0a575c82
commit 45f7030786
3 changed files with 391 additions and 10 deletions

View file

@ -0,0 +1,276 @@
"""MiniMax TTS region, endpoint, and credential selection tests."""
from unittest.mock import MagicMock, patch
import pytest
from tools.tts_tool import (
DEFAULT_MINIMAX_BASE_URL,
DEFAULT_MINIMAX_CN_BASE_URL,
_generate_minimax_tts,
_resolve_minimax_tts_runtime,
check_tts_requirements,
)
GLOBAL_CREDENTIAL_SENTINEL = "FAKE_GLOBAL_CREDENTIAL"
CN_CREDENTIAL_SENTINEL = "FAKE_CN_CREDENTIAL"
@pytest.fixture(autouse=True)
def _fake_minimax_credentials(monkeypatch):
values = {}
monkeypatch.setattr(
"tools.tts_tool.get_env_value",
lambda name, default=None: values.get(name, default),
)
return values
@pytest.mark.parametrize(
("config", "credentials", "expected"),
[
pytest.param(
{},
{"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL},
(
"global",
DEFAULT_MINIMAX_BASE_URL,
"MINIMAX_API_KEY",
GLOBAL_CREDENTIAL_SENTINEL,
),
id="global-only",
),
pytest.param(
{},
{"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL},
(
"cn",
DEFAULT_MINIMAX_CN_BASE_URL,
"MINIMAX_CN_API_KEY",
CN_CREDENTIAL_SENTINEL,
),
id="china-only",
),
pytest.param(
{},
{
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
},
(
"global",
DEFAULT_MINIMAX_BASE_URL,
"MINIMAX_API_KEY",
GLOBAL_CREDENTIAL_SENTINEL,
),
id="both-default-to-global",
),
pytest.param(
{"minimax": {"region": "global"}},
{
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
},
(
"global",
DEFAULT_MINIMAX_BASE_URL,
"MINIMAX_API_KEY",
GLOBAL_CREDENTIAL_SENTINEL,
),
id="explicit-global",
),
pytest.param(
{"minimax": {"region": "cn"}},
{
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
},
(
"cn",
DEFAULT_MINIMAX_CN_BASE_URL,
"MINIMAX_CN_API_KEY",
CN_CREDENTIAL_SENTINEL,
),
id="explicit-china",
),
],
)
def test_runtime_selection_matrix(
_fake_minimax_credentials,
config,
credentials,
expected,
):
_fake_minimax_credentials.update(credentials)
runtime = _resolve_minimax_tts_runtime(config)
assert (
runtime.region,
runtime.endpoint,
runtime.credential_source,
runtime.api_key,
) == expected
@pytest.mark.parametrize(
("region", "credentials", "missing_source"),
[
pytest.param(
"global",
{"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL},
"MINIMAX_API_KEY",
id="global-does-not-borrow-china-key",
),
pytest.param(
"cn",
{"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL},
"MINIMAX_CN_API_KEY",
id="china-does-not-borrow-global-key",
),
],
)
def test_explicit_region_requires_matching_credential(
_fake_minimax_credentials,
region,
credentials,
missing_source,
):
_fake_minimax_credentials.update(credentials)
with pytest.raises(ValueError, match=missing_source):
_resolve_minimax_tts_runtime({"minimax": {"region": region}})
@pytest.mark.parametrize(
("region", "base_url"),
[
pytest.param(
"global",
DEFAULT_MINIMAX_CN_BASE_URL,
id="global-key-china-endpoint",
),
pytest.param(
"cn",
DEFAULT_MINIMAX_BASE_URL,
id="china-key-global-endpoint",
),
],
)
def test_official_cross_region_endpoint_is_rejected(
_fake_minimax_credentials,
region,
base_url,
):
_fake_minimax_credentials.update(
{
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
}
)
with pytest.raises(ValueError, match="points to the .* MiniMax endpoint"):
_resolve_minimax_tts_runtime(
{"minimax": {"region": region, "base_url": base_url}}
)
@pytest.mark.parametrize(
("region", "expected_url", "expected_key"),
[
pytest.param(
"global",
DEFAULT_MINIMAX_BASE_URL,
GLOBAL_CREDENTIAL_SENTINEL,
id="global-pair",
),
pytest.param(
"cn",
DEFAULT_MINIMAX_CN_BASE_URL,
CN_CREDENTIAL_SENTINEL,
id="china-pair",
),
],
)
def test_generate_uses_one_region_bound_endpoint_and_header(
tmp_path,
_fake_minimax_credentials,
region,
expected_url,
expected_key,
):
_fake_minimax_credentials.update(
{
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
}
)
response = MagicMock()
response.json.return_value = {
"base_resp": {"status_code": 0},
"data": {"audio": "0001"},
}
output = tmp_path / f"{region}.mp3"
with patch("requests.post", return_value=response) as post:
result = _generate_minimax_tts(
"hello",
str(output),
{"minimax": {"region": region}},
)
assert result == str(output)
assert output.read_bytes() == b"\x00\x01"
assert post.call_args.args == (expected_url,)
assert (
post.call_args.kwargs["headers"]["Authorization"]
== f"Bearer {expected_key}"
)
@pytest.mark.parametrize(
("config", "credentials", "expected"),
[
pytest.param(
{"provider": "minimax"},
{"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL},
True,
id="china-only-available",
),
pytest.param(
{"provider": "minimax", "minimax": {"region": "cn"}},
{"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL},
False,
id="selected-region-missing",
),
pytest.param(
{"provider": "minimax", "minimax": {"region": "invalid"}},
{
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
},
False,
id="invalid-region",
),
],
)
def test_availability_uses_atomic_runtime(
monkeypatch,
_fake_minimax_credentials,
config,
credentials,
expected,
):
_fake_minimax_credentials.update(credentials)
monkeypatch.setattr("tools.tts_tool._load_tts_config", lambda: config)
assert check_tts_requirements() is expected
def test_runtime_repr_excludes_raw_credential(_fake_minimax_credentials):
_fake_minimax_credentials["MINIMAX_API_KEY"] = GLOBAL_CREDENTIAL_SENTINEL
runtime = _resolve_minimax_tts_runtime({})
assert GLOBAL_CREDENTIAL_SENTINEL not in repr(runtime)

View file

@ -6,7 +6,7 @@ Built-in TTS providers:
- Edge TTS (default, free, no API key): Microsoft Edge neural voices
- ElevenLabs (premium): High-quality voices, needs ELEVENLABS_API_KEY
- OpenAI TTS: Good quality, needs OPENAI_API_KEY
- MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY
- MiniMax TTS: High-quality with voice cloning, needs the selected region's key
- Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY
- Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY
- xAI TTS: Grok voices, uses xAI Grok OAuth credentials or XAI_API_KEY
@ -49,6 +49,7 @@ import subprocess
import tempfile
import threading
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Dict, Any, Optional
from urllib.parse import urljoin, urlparse
@ -221,6 +222,7 @@ DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_MINIMAX_MODEL = "speech-02-hd"
DEFAULT_MINIMAX_VOICE_ID = "English_expressive_narrator"
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2"
DEFAULT_MINIMAX_CN_BASE_URL = "https://api.minimaxi.com/v1/t2a_v2"
DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral
DEFAULT_XAI_VOICE_ID = "eve"
@ -491,6 +493,88 @@ def _get_provider(tts_config: Dict[str, Any]) -> str:
return (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
@dataclass(frozen=True)
class _MiniMaxTTSRuntime:
"""A region-bound MiniMax endpoint and credential.
The credential is excluded from ``repr`` so diagnostics cannot expose it
accidentally.
"""
region: str
endpoint: str
credential_source: str
api_key: str = field(repr=False)
def _resolve_minimax_tts_runtime(
tts_config: Dict[str, Any],
) -> _MiniMaxTTSRuntime:
"""Select MiniMax TTS region, endpoint, and credential atomically.
An explicit ``tts.minimax.region`` wins. Without one, the legacy global
credential wins when present; a China credential is selected only when it
is the sole configured MiniMax credential.
"""
mm_config = tts_config.get("minimax", {})
if not isinstance(mm_config, dict):
mm_config = {}
credentials = {
"global": (
"MINIMAX_API_KEY",
str(_resolve_provider_key("MINIMAX_API_KEY", "minimax") or "").strip(),
),
"cn": (
"MINIMAX_CN_API_KEY",
str(_resolve_provider_key("MINIMAX_CN_API_KEY", "minimax") or "").strip(),
),
}
endpoints = {
"global": DEFAULT_MINIMAX_BASE_URL,
"cn": DEFAULT_MINIMAX_CN_BASE_URL,
}
configured_region = str(mm_config.get("region") or "").strip().lower()
if configured_region and configured_region not in endpoints:
raise ValueError("tts.minimax.region must be 'global' or 'cn'")
if configured_region:
region = configured_region
elif credentials["global"][1]:
region = "global"
elif credentials["cn"][1]:
region = "cn"
else:
region = "global"
credential_source, api_key = credentials[region]
if not api_key:
raise ValueError(
f"{credential_source} not set for MiniMax TTS region {region!r}"
)
endpoint = str(mm_config.get("base_url") or endpoints[region]).strip()
endpoint_host = (urlparse(endpoint).hostname or "").lower()
official_region_hosts = {
"global": frozenset({"api.minimax.io", "api.minimax.chat"}),
"cn": frozenset({"api.minimaxi.com"}),
}
other_region = "cn" if region == "global" else "global"
if endpoint_host in official_region_hosts[other_region]:
raise ValueError(
f"tts.minimax.base_url points to the {other_region!r} MiniMax endpoint "
f"but region is {region!r}"
)
return _MiniMaxTTSRuntime(
region=region,
endpoint=endpoint,
credential_source=credential_source,
api_key=api_key,
)
# ===========================================================================
# Custom command providers (type: command under tts.providers.<name>)
# ===========================================================================
@ -1695,14 +1779,14 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
"""
import requests
api_key = (_resolve_provider_key("MINIMAX_API_KEY", "minimax") or "")
if not api_key:
raise ValueError("MINIMAX_API_KEY not set. Get one at https://platform.minimax.io/")
runtime = _resolve_minimax_tts_runtime(tts_config)
mm_config = tts_config.get("minimax", {})
if not isinstance(mm_config, dict):
mm_config = {}
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
base_url = runtime.endpoint
speed = mm_config.get("speed", 1.0)
vol = mm_config.get("vol", 1.0)
pitch = mm_config.get("pitch", 0)
@ -1724,7 +1808,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"Authorization": f"Bearer {runtime.api_key}",
}
# Detect endpoint from URL
@ -3000,7 +3084,11 @@ def check_tts_requirements() -> bool:
return False
return bool(_resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra"))
if provider == "minimax":
return bool(_resolve_provider_key("MINIMAX_API_KEY", "minimax"))
try:
_resolve_minimax_tts_runtime(tts_config)
except ValueError:
return False
return True
if provider == "xai":
try:
from tools.xai_http import resolve_xai_http_credentials
@ -3368,12 +3456,20 @@ if __name__ == "__main__":
" API Key: "
f"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}"
)
print(f" MiniMax: {'API key set' if get_env_value('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}")
config = _load_tts_config()
try:
minimax_runtime = _resolve_minimax_tts_runtime(config)
minimax_status = (
f"API key set ({minimax_runtime.region}, "
f"{minimax_runtime.credential_source})"
)
except ValueError as exc:
minimax_status = f"unavailable ({exc})"
print(f" MiniMax: {minimax_status}")
print(f" Piper: {'installed' if _check_piper_available() else 'not installed (pip install piper-tts)'}")
print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}")
print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}")
config = _load_tts_config()
provider = _get_provider(config)
print(f" Configured provider: {provider}")

View file

@ -21,7 +21,7 @@ Convert text to speech with ten providers:
| **Edge TTS** (default) | Good | Free | None needed |
| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` |
| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |
| **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` |
| **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` or `MINIMAX_CN_API_KEY` |
| **Mistral (Voxtral TTS)** | Excellent | Paid | `MISTRAL_API_KEY` |
| **Google Gemini TTS** | Excellent | Free tier | `GEMINI_API_KEY` |
| **xAI TTS** | Excellent | Paid | `XAI_API_KEY` |
@ -58,11 +58,13 @@ tts:
speed: 1.0 # 0.25 - 4.0
# language: "es" # Sent as lang_code — only for OpenAI-compatible endpoints that support it (e.g. Kokoro)
minimax:
region: "global" # "global" or "cn"; see selection rules below
model: "speech-02-hd" # speech-02-hd (default), speech-02-turbo
voice_id: "English_Graceful_Lady" # See https://platform.minimax.io/faq/system-voice-id
speed: 1 # 0.5 - 2.0
vol: 1 # 0 - 10
pitch: 0 # -12 - 12
# base_url: "https://tts.example/v1/t2a_v2" # Optional endpoint override for the selected region
mistral:
model: "voxtral-mini-tts-2603"
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
@ -102,6 +104,13 @@ tts:
# normalize_audio: true
```
MiniMax TTS selects its region, endpoint, and credential together:
- `region: "global"` uses `https://api.minimax.io/v1/t2a_v2` with `MINIMAX_API_KEY`.
- `region: "cn"` uses `https://api.minimaxi.com/v1/t2a_v2` with `MINIMAX_CN_API_KEY`.
- If `region` is omitted, `MINIMAX_API_KEY` keeps precedence for backward compatibility. If only `MINIMAX_CN_API_KEY` is configured, Hermes selects `cn`.
- An explicitly selected region must have its matching credential. Hermes never borrows the other region's key. A `base_url` override does not change the selected credential, and an override pointing at the other region's official endpoint is rejected.
**Speed control**: The global `tts.speed` value applies to all providers by default. Each provider can override it with its own `speed` setting (e.g., `tts.openai.speed: 1.5`). Provider-specific speed takes precedence over the global value. Default is `1.0` (normal speed).
### Gemini Persona Prompts