{"repo":"NousResearch/hermes-agent","public_id":"20869","display_label":"MiniMax TTS default model and endpoint updates","summary":"These issues and pull requests address outdated MiniMax TTS configuration defaults, specifically updating the model from speech-01 to speech-02 and correcting the API endpoint URL. The members appear to cover the same fix from multiple angles or iterations.","subsystem":"skills","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25431,"merged_at":"2026-05-14T05:04:28+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-14T05:04:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":20869,"pr_number":25431,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-14T05:04:28+00:00"}],"members":[{"kind":"issue","number":20869,"title":"fix(tts): MiniMax TTS defaults are outdated — model speech-01 and old API endpoint","url":null,"state":"closed"},{"kind":"pr","number":20870,"title":"fix(tts): update MiniMax TTS default model to speech-02 and correct API endpoint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c875c0dc1","number":0},{"kind":"commit","value":"7f08cb594","number":0},{"kind":"pr","value":"#25431","number":25431}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20933,"title":"fix(tts): MiniMax TTS defaults — speech-02 + api.minimaxi.com endpoint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20870","number":20870}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":25431,"title":"fix(tts): MiniMax TTS — switch defaults to live t2a_v2 endpoint","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_tts_speed.py","tools/tts_tool.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -223,6 +223,7 @@\n     \"[redacted:email]\": \"htsh\",\n     \"[redacted:email]\": \"pty819\",\n     \"pty819@users.noreply.github.com\": \"pty819\",\n+    \"14341805+pty819@users.noreply.github.com\": \"pty819\",\n     \"[redacted:email]\": \"chennest\",\n     # Curator fixes (Apr 30 2026)\n     \"[redacted:email]\": \"y0shua1ee\","]},{"file":"tests/tools/test_tts_speed.py","hunks":["@@ -8,7 +8,12 @@\n \n @pytest.fixture(autouse=True)\n def clean_env(monkeypatch):\n-    for key in (\"OPENAI_API_KEY\", \"MINIMAX_API_KEY\", \"HERMES_SESSION_PLATFORM\"):\n+    for key in (\n+        \"OPENAI_API_KEY\",\n+        \"MINIMAX_API_KEY\",\n+        \"MINIMAX_GROUP_ID\",\n+        \"HERMES_SESSION_PLATFORM\",\n+    ):\n         monkeypatch.delenv(key, raising=False)\n \n ","@@ -110,37 +115,126 @@ def test_speed_clamped_high(self, tmp_path, monkeypatch):\n \n \n # ---------------------------------------------------------------------------\n-# MiniMax TTS (new API: raw audio, no speed/voice_setting)\n+# MiniMax TTS (t2a_v2 endpoint: nested voice_setting/audio_setting,\n+# JSON response with hex-encoded audio.  Falls back to the legacy\n+# text_to_speech endpoint shape when the base_url points at it.)\n # ---------------------------------------------------------------------------\n \n-class TestMinimaxTtsSpeed:\n+\n+def _hex_response(payload_audio: bytes = b\"\\x00\\x01\\x02\\x03\"):\n+    \"\"\"Build a mock response shaped like a successful t2a_v2 reply.\"\"\"\n+    mock_response = MagicMock()\n+    mock_response.status_code = 200\n+    mock_response.headers = {\"Content-Type\": \"application/json\"}\n+    mock_response.json.return_value = {\n+        \"data\": {\"audio\": payload_audio.hex(), \"status\": 2},\n+        \"base_resp\": {\"status_code\": 0, \"status_msg\": \"success\"},\n+    }\n+    return mock_response\n+\n+\n+class TestMinimaxTtsT2aV2:\n+    \"\"\"Default path: base_url contains 't2a_v2'.\"\"\"\n+\n+    def _run(self, tts_config, tmp_path, monkeypatch, response=None):\n+        monkeypatch.setenv(\"MINIMAX_API_KEY\", \"test-key\")\n+        resp = response if response is not None else _hex_response()\n+        with patch(\"requests.post\", return_value=resp) as mock_post:\n+            from tools.tts_tool import _generate_minimax_tts\n+            output = _generate_minimax_tts(\"Hello\", str(tmp_path / \"out.mp3\"), tts_config)\n+        return mock_post, output\n+\n+    def test_nested_payload(self, tmp_path, monkeypatch):\n+        \"\"\"Default endpoint uses nested voice_setting / audio_setting.\"\"\"\n+        mock_post, _ = self._run({}, tmp_path, monkeypatch)\n+        payload = mock_post.call_args[1][\"json\"]\n+        assert payload[\"model\"] == \"speech-02-hd\"\n+        assert payload[\"text\"] == \"Hello\"\n+        assert \"voice_setting\" in payload\n+        assert payload[\"voice_setting\"][\"voice_id\"] == \"English_expressive_narrator\"\n+        assert \"audio_setting\" in payload\n+        assert payload[\"audio_setting\"][\"format\"] == \"mp3\"\n+        # Don't send flat top-level voice_id alongside nested voice_setting.\n+        assert \"voice_id\" not in payload\n+\n+    def test_decodes_hex_audio(self, tmp_path, monkeypatch):\n+        \"\"\"t2a_v2 hex-encoded audio is decoded and written verbatim.\"\"\"\n+        _, output = self._run({}, tmp_path, monkeypatch)\n+        with open(output, \"rb\") as f:\n+            assert f.read() == b\"\\x00\\x01\\x02\\x03\"\n+\n+    def test_default_url_is_t2a_v2(self, tmp_path, monkeypatch):\n+        \"\"\"Default base URL points at the live t2a_v2 endpoint.\"\"\"\n+        mock_post, _ = self._run({}, tmp_path, monkeypatch)\n+        url = mock_post.call_args[0][0]\n+        assert \"t2a_v2\" in url\n+        assert \"api.minimax.io\" in url\n+\n+    def test_group_id_from_config(self, tmp_path, monkeypatch):\n+        \"\"\"group_id from config attaches as ?GroupId=<id>.\"\"\"\n+        mock_post, _ = self._run({\"minimax\": {\"group_id\": \"G123\"}}, tmp_path, monkeypatch)\n+        url = mock_post.call_args[0][0]\n+        assert \"GroupId=G123\" in url\n+\n+    def test_group_id_from_env(self, tmp_path, monkeypatch):\n+        \"\"\"MINIMAX_GROUP_ID env var attaches as ?GroupId=<id>.\"\"\"\n+        monkeypatch.setenv(\"MINIMAX_GROUP_ID\", \"G456\")\n+        mock_post, _ = self._run({}, tmp_path, monkeypatch)\n+        url = mock_post.call_args[0][0]\n+        assert \"GroupId=G456\" in url\n+\n+    def test_group_id_already_in_url_left_alone(self, tmp_path, monkeypatch):\n+        \"\"\"If user already set GroupId in base_url, don't double-append it.\"\"\"\n+        cfg = {\"minimax\": {\n+            \"base_url\": \"https://api.minimax.io/v1/t2a_v2?GroupId=PRESET\",\n+            \"group_id\": \"IGNORED\",\n+        }}\n+        mock_post, _ = self._run(cfg, tmp_path, monkeypatch)\n+        url = mock_post.call_args[0][0]\n+        assert url.count(\"GroupId=\") == 1\n+        assert \"GroupId=PRESET\" in url\n+\n+    def test_api_error_raises(self, tmp_path, monkeypatch):\n+        \"\"\"Non-zero base_resp.status_code surfaces as RuntimeError.\"\"\"\n+        resp = MagicMock()\n+        resp.status_code = 200\n+        resp.headers = {\"Content-Type\": \"application/json\"}\n+        resp.json.return_value = {\n+            \"data\": {\"audio\": \"\", \"status\": 1},\n+            \"base_resp\": {\"status_code\": 2013, \"status_msg\": \"invalid voice\"},\n+        }\n+        with pytest.raises(RuntimeError, match=\"2013\"):\n+            self._run({}, tmp_path, monkeypatch, response=resp)\n+\n+\n+class TestMinimaxTtsLegacyTextToSpeech:\n+    \"\"\"Legacy path: caller pins base_url to the old text_to_speech endpoint.\"\"\"\n+\n+    LEGACY_URL = \"https://api.minimax.chat/v1/text_to_speech\"\n+\n     def _run(self, tts_config, tmp_path, monkeypatch):\n         monkeypatch.setenv(\"MINIMAX_API_KEY\", \"test-key\")\n+        cfg = dict(tts_config)\n+        cfg.setdefault(\"minimax\", {})[\"base_url\"] = self.LEGACY_URL\n         mock_response = MagicMock()\n         mock_response.status_code = 200\n         mock_response.headers = {\"Content-Type\": \"audio/mpeg\"}\n         mock_response.content = b\"\\x00\\x01\\x02\\x03\"\n-\n-        # requests is imported locally inside _generate_minimax_tts\n         with patch(\"requests.post\", return_value=mock_response) as mock_post:\n             from tools.tts_tool import _generate_minimax_tts\n-            output = _generate_minimax_tts(\"Hello\", str(tmp_path / \"out.mp3\"), tts_config)\n+            output = _generate_minimax_tts(\"Hello\", str(tmp_path / \"out.mp3\"), cfg)\n         return mock_post, output\n \n-    def test_simple_payload(self, tmp_path, monkeypatch):\n-        \"\"\"New API uses flat payload with model, text, voice_id.\"\"\"\n+    def test_flat_payload(self, tmp_path, monkeypatch):\n+        \"\"\"Legacy endpoint keeps the flat {model, text, voice_id} shape.\"\"\"\n         mock_post, _ = self._run({}, tmp_path, monkeypatch)\n         payload = mock_post.call_args[1][\"json\"]\n-        assert \"model\" in payload\n-        assert \"text\" in payload\n         assert \"voice_id\" in payload\n         assert \"voice_setting\" not in payload\n         assert \"audio_setting\" not in payload\n-        assert \"stream\" not in payload\n \n     def test_writes_raw_audio(self, tmp_path, monkeypatch):\n-        \"\"\"New API returns raw bytes written directly to file.\"\"\"\n+        \"\"\"Legacy endpoint returns raw bytes written directly to file.\"\"\"\n         _, output = self._run({}, tmp_path, monkeypatch)\n-        assert output == str(tmp_path / \"out.mp3\")\n         with open(output, \"rb\") as f:\n             assert f.read() == b\"\\x00\\x01\\x02\\x03\""]},{"file":"tools/tts_tool.py","hunks":["@@ -159,9 +159,9 @@ def _import_piper():\n DEFAULT_PIPER_VOICE = \"en_US-lessac-medium\"  # balanced size/quality\n DEFAULT_OPENAI_VOICE = \"alloy\"\n DEFAULT_OPENAI_BASE_URL = \"https://api.openai.com/v1\"\n-DEFAULT_MINIMAX_MODEL = \"speech-01\"\n-DEFAULT_MINIMAX_VOICE_ID = \"female-shaonv\"\n-DEFAULT_MINIMAX_BASE_URL = \"https://api.minimax.chat/v1/text_to_speech\"\n+DEFAULT_MINIMAX_MODEL = \"speech-02-hd\"\n+DEFAULT_MINIMAX_VOICE_ID = \"English_expressive_narrator\"\n+DEFAULT_MINIMAX_BASE_URL = \"https://api.minimax.io/v1/t2a_v2\"\n DEFAULT_MISTRAL_TTS_MODEL = \"voxtral-mini-tts-2603\"\n DEFAULT_MISTRAL_TTS_VOICE_ID = \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\"  # Paul - Neutral\n DEFAULT_XAI_VOICE_ID = \"eve\"","@@ -960,11 +960,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -\n # ===========================================================================\n def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:\n     \"\"\"\n-    Generate audio using MiniMax TTS API (v1/text_to_speech).\n+    Generate audio using MiniMax TTS API.\n \n-    The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload\n-    and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike\n-    the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio.\n+    Supports two endpoints:\n+    - v1/text_to_speech: simple payload, returns raw audio (Content-Type: audio/mpeg)\n+    - v1/t2a_v2: nested voice_setting/audio_setting, returns JSON with hex-encoded audio\n \n     Args:\n         text: Text to convert (max 10,000 characters).","@@ -984,56 +984,106 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any\n     model = mm_config.get(\"model\", DEFAULT_MINIMAX_MODEL)\n     voice_id = mm_config.get(\"voice_id\", DEFAULT_MINIMAX_VOICE_ID)\n     base_url = mm_config.get(\"base_url\", DEFAULT_MINIMAX_BASE_URL)\n-\n-    payload = {\n-        \"model\": model,\n-        \"text\": text,\n-        \"voice_id\": voice_id,\n-    }\n+    speed = mm_config.get(\"speed\", 1.0)\n+    vol = mm_config.get(\"vol\", 1.0)\n+    pitch = mm_config.get(\"pitch\", 0)\n+    emotion = mm_config.get(\"emotion\", \"neutral\")\n+    sample_rate = mm_config.get(\"sample_rate\", 32000)\n+    bitrate = mm_config.get(\"bitrate\", 128000)\n+\n+    # MiniMax accounts scope TTS requests by GroupId.  When present, the docs\n+    # show it as a ?GroupId=<id> query param on the t2a_v2 URL.  Accept it\n+    # from config or from the MINIMAX_GROUP_ID env var; only attach when the\n+    # URL doesn't already carry one.\n+    group_id = (\n+        str(mm_config.get(\"group_id\") or \"\").strip()\n+        or (get_env_value(\"MINIMAX_GROUP_ID\") or \"\").strip()\n+    )\n+    if group_id and \"GroupId=\" not in base_url:\n+        sep = \"&\" if \"?\" in base_url else \"?\"\n+        base_url = f\"{base_url}{sep}GroupId={group_id}\"\n \n     headers = {\n         \"Content-Type\": \"application/json\",\n         \"Authorization\": f\"Bearer {api_key}\",\n     }\n \n-    response = requests.post(base_url, json=payload, headers=headers, timeout=60)\n+    # Detect endpoint from URL\n+    is_t2a_v2 = \"t2a_v2\" in base_url\n \n-    content_type = response.headers.get(\"Content-Type\", \"\")\n+    if is_t2a_v2:\n+        # t2a_v2 endpoint: nested voice_setting/audio_setting structure\n+        payload = {\n+            \"model\": model,\n+            \"text\": text,\n+            \"voice_setting\": {\n+                \"voice_id\": voice_id,\n+                \"speed\": speed,\n+                \"vol\": vol,\n+                \"pitch\": pitch,\n+                \"emotion\": emotion,\n+            },\n+            \"audio_setting\": {\n+                \"sample_rate\": sample_rate,\n+                \"bitrate\": bitrate,\n+                \"format\": \"mp3\",\n+                \"channel\": 1,\n+            },\n+        }\n+    else:\n+        # text_to_speech endpoint: flat payload\n+        payload = {\n+            \"model\": model,\n+            \"text\": text,\n+            \"voice_id\": voice_id,\n+        }\n \n-    if \"audio/\" in content_type:\n-        # New API: returns raw audio directly\n-        with open(output_path, \"wb\") as f:\n-            f.write(response.content)\n-        return output_path\n+    response = requests.post(base_url, json=payload, headers=headers, timeout=60)\n \n-    # Legacy / fallback: try parsing as JSON with hex-encoded audio\n-    try:\n+    if is_t2a_v2:\n+        # t2a_v2 returns JSON with hex-encoded audio\n         result = response.json()\n-    except Exception:\n-        response.raise_for_status()\n-        raise RuntimeError(\n-            f\"MiniMax TTS returned unexpected Content-Type '{content_type}' \"\n-            f\"({len(response.content)} bytes)\"\n-        )\n+        base_resp = result.get(\"base_resp\", {})\n+        status_code = base_resp.get(\"status_code\", -1)\n \n-    base_resp = result.get(\"base_resp\", {})\n-    status_code = base_resp.get(\"status_code\", -1)\n+        if status_code != 0:\n+            status_msg = base_resp.get(\"status_msg\", \"unknown error\")\n+            raise RuntimeError(f\"MiniMax TTS API error (code {status_code}): {status_msg}\")\n \n-    if status_code != 0:\n-        status_msg = base_resp.get(\"status_msg\", \"unknown error\")\n-        raise RuntimeError(f\"MiniMax TTS API error (code {status_code}): {status_msg}\")\n+        hex_audio = result.get(\"data\", {}).get(\"audio\", \"\")\n+        if not hex_audio:\n+            raise RuntimeError(\"MiniMax TTS returned empty audio data\")\n \n-    hex_audio = result.get(\"data\", {}).get(\"audio\", \"\")\n-    if not hex_audio:\n-        raise RuntimeError(\"MiniMax TTS returned empty audio data\")\n+        audio_bytes = bytes.fromhex(hex_audio)\n+        with open(output_path, \"wb\") as f:\n+            f.write(audio_bytes)\n+        return output_path\n \n-    # Legacy: hex-encoded audio\n-    audio_bytes = bytes.fromhex(hex_audio)\n+    else:\n+        # text_to_speech returns raw audio directly\n+        content_type = response.headers.get(\"Content-Type\", \"\")\n \n-    with open(output_path, \"wb\") as f:\n-        f.write(audio_bytes)\n+        if \"audio/\" in content_type:\n+            with open(output_path, \"wb\") as f:\n+                f.write(response.content)\n+            return output_path\n \n-    return output_path\n+        # Fallback: try parsing as JSON\n+        try:\n+            result = response.json()\n+            base_resp = result.get(\"base_resp\", {})\n+            status_code = base_resp.get(\"status_code\", -1)\n+            if status_code != 0:\n+                status_msg = base_resp.get(\"status_msg\", \"unknown error\")\n+                raise RuntimeError(f\"MiniMax TTS API error (code {status_code}): {status_msg}\")\n+        except Exception:\n+            response.raise_for_status()\n+            raise RuntimeError(\n+                f\"MiniMax TTS returned unexpected Content-Type '{content_type}' \"\n+                f\"({len(response.content)} bytes)\"\n+            )\n+\n+        raise RuntimeError(\"MiniMax TTS returned no audio data\")\n \n \n # ===========================================================================\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}