fix(deps): unbreak [all] install — drop mistralai while PyPI quarantined (#24205)

The `mistralai` PyPI package was quarantined on 2026-05-12 after a
malicious 2.4.6 release. Every fresh resolve (AUR makepkg, Docker build,
CI run, install.sh first-run) currently fails on
`mistralai>=2.3.0,<3` because PyPI returns zero candidates.

Existing users running `hermes update` mostly didn't notice — `hermes
update` falls back from `.[all]` to per-extra retries and silently
skips mistral with a warning that scrolls past. But fresh installs
hard-fail or lose every other extra.

Changes:
- pyproject.toml: drop `hermes-agent[mistral]` from `[all]` and
  `[termux-all]`. The `mistral` extra itself is preserved so users
  can opt back in once PyPI un-quarantines.
- hermes_cli/tools_config.py: hide Mistral Voxtral TTS from the
  `hermes tools` provider picker until restored.
- hermes_cli/web_server.py: drop "mistral" from dashboard STT options.
- tools/transcription_tools.py: explicit `provider: mistral` returns
  "none" with a clear status message; auto-detect skips mistral.
- tools/tts_tool.py: dispatcher returns a clear "temporarily disabled"
  error before any SDK import attempt (avoids cached-stale-package
  surprises).
- tests/tools/: update three test files to assert the new disabled
  behavior. Each test docstring records why and points at the rollback
  trigger (PyPI un-quarantines mistralai).

Restore plan: revert this commit once the package is available on PyPI
again. The behavior change is intentional and documented in code
comments + test docstrings to make the rollback trivial.

Validation:
- scripts/run_tests.sh tests/tools/ -k 'mistral or stt or tts' →
  425/425 passing.

Refs: https://pypi.org/simple/mistralai/ (currently
"pypi:project-status: quarantined").
This commit is contained in:
Teknium 2026-05-11 23:02:15 -07:00 committed by GitHub
parent 407683b72d
commit 99ad2d1372
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 90 additions and 48 deletions

View file

@ -69,6 +69,12 @@ class TestProviderSelectionGate:
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
def test_explicit_mistral_sees_dotenv(self):
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
Even with the dotenv key visible, explicit `provider: mistral` must
return "none" with a warning. Restore the previous behavior once
`mistralai` is un-quarantined on PyPI.
"""
from tools import transcription_tools as tt
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
@ -76,7 +82,7 @@ class TestProviderSelectionGate:
patch.object(tt, "_has_local_command", return_value=False), \
patch("hermes_cli.config.load_env",
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
def test_explicit_xai_sees_dotenv(self):
from tools import transcription_tools as tt

View file

@ -978,16 +978,23 @@ class TestTranscribeMistral:
# ============================================================================
class TestGetProviderMistral:
"""Mistral-specific provider selection tests."""
"""Mistral-specific provider selection tests.
Mistral STT is intentionally disabled in 2026-05-12+ while the
`mistralai` PyPI package is quarantined. These tests document that
explicit `provider: mistral` always returns "none" with a warning, and
that auto-detect skips mistral entirely.
"""
def test_mistral_when_key_and_sdk_available(self, monkeypatch):
"""Even with key + SDK, explicit mistral returns 'none' (disabled)."""
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "mistral"}) == "mistral"
assert _get_provider({"provider": "mistral"}) == "none"
def test_mistral_explicit_no_key_returns_none(self, monkeypatch):
"""Explicit mistral with no key returns none — no cross-provider fallback."""
"""Explicit mistral with no key returns none."""
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
@ -1000,18 +1007,23 @@ class TestGetProviderMistral:
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "mistral"}) == "none"
def test_auto_detect_mistral_after_openai(self, monkeypatch):
"""Auto-detect: mistral is tried after openai when both are unavailable."""
def test_auto_detect_skips_mistral(self, monkeypatch):
"""Auto-detect intentionally skips mistral (quarantine workaround).
With no other provider available but MISTRAL_API_KEY set, the result
must be 'none' mistral is no longer in the auto-detect chain.
"""
monkeypatch.delenv("GROQ_API_KEY", raising=False)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._has_local_command", return_value=False), \
patch("tools.transcription_tools._HAS_OPENAI", False), \
patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "mistral"
assert _get_provider({}) == "none"
def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch):
"""Auto-detect: openai is preferred over mistral (both paid, openai more common)."""
@ -1285,8 +1297,13 @@ class TestGetProviderXAI:
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "xai"
def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch):
"""Auto-detect: mistral is preferred over xai."""
def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch):
"""Auto-detect skips mistral entirely (quarantine) — xai wins.
Even with MISTRAL_API_KEY set, mistral is no longer in the
auto-detect chain. xai is the next-best fallback when the
local/groq/openai chain is unavailable.
"""
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
monkeypatch.setenv("XAI_API_KEY", "xai-test")
monkeypatch.delenv("GROQ_API_KEY", raising=False)
@ -1297,7 +1314,7 @@ class TestGetProviderXAI:
patch("tools.transcription_tools._HAS_OPENAI", False), \
patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "mistral"
assert _get_provider({}) == "xai"
def test_auto_detect_no_key_returns_none(self, monkeypatch):
"""Auto-detect: xai skipped when no key is set."""

View file

@ -162,27 +162,34 @@ class TestGenerateMistralTts:
class TestTtsDispatcherMistral:
def test_dispatcher_routes_to_mistral(
def test_dispatcher_returns_disabled_error(
self, tmp_path, mock_mistral_module, monkeypatch
):
"""Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12).
The dispatcher must short-circuit with a clear status message before
attempting any SDK import, even when MISTRAL_API_KEY is set and a
mock SDK is wired in. Restore routing once `mistralai` is
un-quarantined on PyPI.
"""
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()
assert result["success"] is False
assert "temporarily disabled" in result["error"]
assert "quarantined" in result["error"]
# SDK must not have been called.
mock_mistral_module.audio.speech.complete.assert_not_called()
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
"""Same disabled message regardless of SDK presence."""
import json
from tools.tts_tool import text_to_speech_tool
@ -196,7 +203,7 @@ class TestTtsDispatcherMistral:
)
assert result["success"] is False
assert "mistralai" in result["error"]
assert "temporarily disabled" in result["error"]
class TestCheckTtsRequirementsMistral: