* docs(code-execution): document HERMES_* env narrowing + passthrough workaround
The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.
Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.
* fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted
PyPI quarantined mistralai on 2026-05-12 after the malicious 2.4.6
release (Mini Shai-Hulud worm). 2.4.6 has since been removed from the
registry and clean releases resumed (2.4.7 2026-05-25, 2.4.8 2026-05-28).
This rolls back the blanket runtime ban so Voxtral STT + TTS work again,
following the restoration checklist the repo left in pyproject.toml.
Verified against the real SDK: 2.4.8 keeps the import path the code uses
(from mistralai.client import Mistral) and the audio.transcriptions.complete
/ audio.speech.complete surfaces.
Changes:
- pyproject.toml: re-add mistral extra pinned to mistralai==2.4.8; left
OUT of [all] per the 2026-05-12 lazy-install policy (one quarantined
release must not break fresh installs). uv.lock regenerated.
- tools/lazy_deps.py: add stt.mistral / tts.mistral entries so the SDK
lazy-installs on first use (matches edge / elevenlabs).
- tools/transcription_tools.py: restore explicit-provider gate
(_HAS_MISTRAL + key) and auto-detect entry (local>groq>openai>mistral>xai);
_transcribe_mistral lazy-installs before import.
- tools/tts_tool.py: dispatcher routes back to _generate_mistral_tts;
_import_mistral_client lazy-installs the SDK.
- hermes_cli/tools_config.py, hermes_cli/web_server.py: un-hide Mistral
from the TTS provider picker and dashboard STT options.
- hermes_cli/security_advisories.py: KEEP the shai-hulud-2026-05 advisory
(module policy forbids removal) — it is scoped to 2.4.6 only, so it
still warns anyone with the poisoned build cached and never fires on
2.4.8. Summary note updated to reflect the un-quarantine.
- tests: revert the disabled-behavior assertions added by the ban commit
back to routing/positive expectations; add mistral to the
lazy-installable-extras-excluded-from-[all] contract.
Reported by @SkYNewZ (#34503).
Validation: 189 targeted STT/TTS/lazy_deps/metadata tests pass; E2E with
the real mistralai 2.4.8 SDK routes both STT and TTS to mistral.
224 lines
9.2 KiB
Python
224 lines
9.2 KiB
Python
"""Tests for the Mistral (Voxtral) TTS provider in tools/tts_tool.py."""
|
|
|
|
import base64
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_env(monkeypatch):
|
|
for key in ("MISTRAL_API_KEY", "HERMES_SESSION_PLATFORM"):
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_mistral_module():
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
mock_client.__exit__ = MagicMock(return_value=False)
|
|
mock_mistral_cls = MagicMock(return_value=mock_client)
|
|
fake_module = MagicMock()
|
|
fake_module.Mistral = mock_mistral_cls
|
|
with patch.dict("sys.modules", {"mistralai": fake_module, "mistralai.client": fake_module}):
|
|
yield mock_client
|
|
|
|
|
|
class TestGenerateMistralTts:
|
|
def test_missing_api_key_raises_value_error(self, tmp_path, mock_mistral_module):
|
|
from tools.tts_tool import _generate_mistral_tts
|
|
|
|
output_path = str(tmp_path / "test.mp3")
|
|
with pytest.raises(ValueError, match="MISTRAL_API_KEY"):
|
|
_generate_mistral_tts("Hello", output_path, {})
|
|
|
|
def test_successful_generation(self, tmp_path, mock_mistral_module, monkeypatch):
|
|
from tools.tts_tool import _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
audio_content = b"fake-audio-bytes"
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(audio_content).decode()
|
|
)
|
|
|
|
output_path = str(tmp_path / "test.mp3")
|
|
result = _generate_mistral_tts("Hello world", output_path, {})
|
|
|
|
assert result == output_path
|
|
assert (tmp_path / "test.mp3").read_bytes() == audio_content
|
|
mock_mistral_module.audio.speech.complete.assert_called_once()
|
|
mock_mistral_module.__exit__.assert_called_once()
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["input"] == "Hello world"
|
|
assert call_kwargs["response_format"] == "mp3"
|
|
|
|
@pytest.mark.parametrize(
|
|
"extension, expected_format",
|
|
[(".ogg", "opus"), (".wav", "wav"), (".flac", "flac"), (".mp3", "mp3")],
|
|
)
|
|
def test_response_format_from_extension(
|
|
self, tmp_path, mock_mistral_module, monkeypatch, extension, expected_format
|
|
):
|
|
from tools.tts_tool import _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"data").decode()
|
|
)
|
|
|
|
output_path = str(tmp_path / f"test{extension}")
|
|
_generate_mistral_tts("Hi", output_path, {})
|
|
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["response_format"] == expected_format
|
|
|
|
def test_voice_id_passed_when_configured(
|
|
self, tmp_path, mock_mistral_module, monkeypatch
|
|
):
|
|
from tools.tts_tool import _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"data").decode()
|
|
)
|
|
|
|
config = {"mistral": {"voice_id": "my-voice-uuid"}}
|
|
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
|
|
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["voice_id"] == "my-voice-uuid"
|
|
|
|
def test_default_voice_id_when_absent(
|
|
self, tmp_path, mock_mistral_module, monkeypatch
|
|
):
|
|
from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"data").decode()
|
|
)
|
|
|
|
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), {})
|
|
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID
|
|
|
|
def test_default_voice_id_when_empty_string(
|
|
self, tmp_path, mock_mistral_module, monkeypatch
|
|
):
|
|
from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"data").decode()
|
|
)
|
|
|
|
config = {"mistral": {"voice_id": ""}}
|
|
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
|
|
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID
|
|
|
|
def test_api_error_sanitized(self, tmp_path, mock_mistral_module, monkeypatch):
|
|
from tools.tts_tool import _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.side_effect = RuntimeError(
|
|
"secret-key-in-error"
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="RuntimeError") as exc_info:
|
|
_generate_mistral_tts("Hello", str(tmp_path / "test.mp3"), {})
|
|
assert "secret-key-in-error" not in str(exc_info.value)
|
|
|
|
def test_default_model_used(self, tmp_path, mock_mistral_module, monkeypatch):
|
|
from tools.tts_tool import DEFAULT_MISTRAL_TTS_MODEL, _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"data").decode()
|
|
)
|
|
|
|
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), {})
|
|
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["model"] == DEFAULT_MISTRAL_TTS_MODEL
|
|
|
|
def test_model_from_config_overrides_default(
|
|
self, tmp_path, mock_mistral_module, monkeypatch
|
|
):
|
|
from tools.tts_tool import _generate_mistral_tts
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"data").decode()
|
|
)
|
|
|
|
config = {"mistral": {"model": "voxtral-large-tts-9999"}}
|
|
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
|
|
|
|
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
|
|
assert call_kwargs["model"] == "voxtral-large-tts-9999"
|
|
|
|
|
|
class TestTtsDispatcherMistral:
|
|
def test_dispatcher_routes_to_mistral(
|
|
self, tmp_path, mock_mistral_module, monkeypatch
|
|
):
|
|
import json
|
|
|
|
from tools.tts_tool import text_to_speech_tool
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
|
audio_data=base64.b64encode(b"audio").decode()
|
|
)
|
|
|
|
output_path = str(tmp_path / "out.mp3")
|
|
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
|
|
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
|
|
|
|
assert result["success"] is True
|
|
assert result["provider"] == "mistral"
|
|
mock_mistral_module.audio.speech.complete.assert_called_once()
|
|
|
|
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
|
|
import json
|
|
|
|
from tools.tts_tool import text_to_speech_tool
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
with patch(
|
|
"tools.tts_tool._import_mistral_client", side_effect=ImportError("no module")
|
|
), patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
|
|
result = json.loads(
|
|
text_to_speech_tool("Hello", output_path=str(tmp_path / "out.mp3"))
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "mistralai" in result["error"]
|
|
|
|
|
|
class TestCheckTtsRequirementsMistral:
|
|
def test_mistral_sdk_and_key_returns_true(self, mock_mistral_module, monkeypatch):
|
|
from tools.tts_tool import check_tts_requirements
|
|
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \
|
|
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \
|
|
patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \
|
|
patch("tools.tts_tool._check_neutts_available", return_value=False):
|
|
assert check_tts_requirements() is True
|
|
|
|
def test_mistral_key_missing_returns_false(self, mock_mistral_module):
|
|
from tools.tts_tool import check_tts_requirements
|
|
|
|
with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \
|
|
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \
|
|
patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \
|
|
patch("tools.tts_tool._check_neutts_available", return_value=False), \
|
|
patch("tools.tts_tool._check_kittentts_available", return_value=False), \
|
|
patch("tools.tts_tool._check_piper_available", return_value=False), \
|
|
patch("tools.tts_tool._has_any_command_tts_provider", return_value=False):
|
|
assert check_tts_requirements() is False
|