From 45f7030786c4c631bf9a4f8dda64101ebab4de59 Mon Sep 17 00:00:00 2001 From: 3ASiC Date: Thu, 16 Jul 2026 17:38:44 +0800 Subject: [PATCH] fix(tts): bind MiniMax credentials to their region endpoint --- tests/tools/test_tts_minimax_region.py | 276 ++++++++++++++++++++++++ tools/tts_tool.py | 114 +++++++++- website/docs/user-guide/features/tts.md | 11 +- 3 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 tests/tools/test_tts_minimax_region.py diff --git a/tests/tools/test_tts_minimax_region.py b/tests/tools/test_tts_minimax_region.py new file mode 100644 index 000000000000..6a915f47ac9a --- /dev/null +++ b/tests/tools/test_tts_minimax_region.py @@ -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) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 76b130ee28a4..6ded99945e4c 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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.) # =========================================================================== @@ -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}") diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md index 5f1333458190..89275dd94070 100644 --- a/website/docs/user-guide/features/tts.md +++ b/website/docs/user-guide/features/tts.md @@ -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