NousResearch/hermes-agent

TTS/STT API key loading from ~/.hermes/.env

Cluster 17140. Data as of

Nothing open on GitHub. Fix merged in #17434 on 29 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #17434. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #17434. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug was reported where TTS (and STT) tools could not read API keys from ~/.hermes/.env because os.getenv() does not pick up dotenv values. Several fixes address this by switching to a dedicated env value resolver.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #17434 fix(voice): resolve API keys from ~/.hermes/.env in TTS + STT tools (#17140)

    merged to main

    files: tests/tools/test_transcription_dotenv_fallback.py, tests/tools/test_tts_dotenv_fallback.py, tools/transcription_tools.py, tools/tts_tool.py

    Show the diff
    • tests/tools/test_transcription_dotenv_fallback.py
      @@ -0,0 +1,208 @@
      +"""Regression tests for the transcription_tools variant of #17140.
      +
      +Same class of bug as ``tools/tts_tool.py`` (fixed in PR #17163): the STT
      +provider call sites read API keys via ``os.getenv()``, which bypasses
      +``~/.hermes/.env`` entries. These tests confirm each STT provider now
      +consults ``get_env_value()`` and the provider auto-detect + explicit
      +selection gate (``_get_provider``) do the same.
      +"""
      +
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +
      [redacted:email](autouse=True)
      +def isolate_env(monkeypatch):
      +    """Strip every STT-related env var so the test really exercises the
      +    dotenv code path. If any of these survive into the test, the assertion
      +    that ``get_env_value`` was consulted becomes meaningless because
      +    ``os.environ`` already satisfies the lookup.
      +    """
      +    for key in (
      +        "GROQ_API_KEY",
      +        "MISTRAL_API_KEY",
      +        "XAI_API_KEY",
      +        "XAI_STT_BASE_URL",
      +    ):
      +        monkeypatch.delenv(key, raising=False)
      +
      +
      +class TestProviderSelectionGate:
      +    """``_get_provider`` picks the STT backend. If it only consulted
      +    ``os.environ`` a user with keys in ``~/.hermes/.env`` would be told
      +    "no STT available" even though the actual transcribe call would
      +    succeed. The gate lives behind ``is_stt_enabled(stt_config)``, so
      +    configure ``{"enabled": True, "provider": ...}`` for explicit tests.
      +    """
      +
      +    def test_explicit_groq_sees_dotenv(self):
      +        from tools import transcription_tools as tt
      +
      +        with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
      +             patch.object(tt, "_HAS_OPENAI", True), \
      +             patch.object(tt, "_has_local_command", return_value=False), \
      +             patch("hermes_cli.config.load_env",
      +                   return_value={"GROQ_API_KEY": "[redacted:secret-assignment]"}):
      +            assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
      +
      +    def test_explicit_mistral_sees_dotenv(self):
      +        from tools import transcription_tools as tt
      +
      +        with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
      +             patch.object(tt, "_HAS_MISTRAL", True), \
      +             patch.object(tt, "_has_local_command", return_value=False), \
      +             patch("hermes_cli.config.load_env",
      +                   return_value={"MISTRAL_API_KEY": "[redacted:secret-assignment]"}):
      +            assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
      +
      +    def test_explicit_xai_sees_dotenv(self):
      +        from tools import transcription_tools as tt
      +
      +        with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
      +             patch.object(tt, "_has_local_command", return_value=False), \
      +             patch("hermes_cli.config.load_env",
      +                   return_value={"XAI_API_KEY": "[redacted:secret-assignment]"}):
      +            assert tt._get_provider({"enabled": True, "provider": "xai"}) == "xai"
      +
      +    def test_auto_detect_sees_dotenv_groq(self):
      +        """No local backend, no explicit provider — auto-detect should fall
      +        through to Groq when its key lives in dotenv only. Before the fix
      +        it would return 'none'."""
      +        from tools import transcription_tools as tt
      +
      +        with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
      +             patch.object(tt, "_HAS_OPENAI", True), \
      +             patch.object(tt, "_HAS_MISTRAL", False), \
      +             patch.object(tt, "_has_local_command", return_value=False), \
      +             patch.object(tt, "_has_openai_audio_backend", return_value=False), \
      +             patch("hermes_cli.config.load_env",
      +                   return_value={"GROQ_API_KEY": "[redacted:secret-assignment]"}):
      +            # No "provider" key → explicit=False → auto-detect branch
      +            assert tt._get_provider({"enabled": True}) == "groq"
      +
      +
      +class TestTranscribeCallSitesReadDotenv:
      +    """The actual transcribe functions must forward the dotenv-resolved
      +    key into the provider SDK / HTTP call. We mock ``get_env_value`` and
      +    capture what gets passed through."""
      +
      +    def test_transcribe_groq_forwards_dotenv_key(self):
      +        from tools import transcription_tools as tt
      +
      +        seen_keys: list = []
      +
      +        class FakeOpenAIClient:
      +            def __init__(self, *, api_key=None, base_url=None, timeout=None, max_retries=None):
      +                seen_keys.append(api_key)
      +                self.audio = MagicMock()
      +                self.audio.transcriptions.create.return_value = "hello"
      +            def close(self):
      +                pass
      +
      +        fake_openai_module = MagicMock()
      +        fake_openai_module.OpenAI = FakeOpenAIClient
      +        fake_openai_module.APIError = Exception
      +        fake_openai_module.APIConnectionError = Exception
      +        fake_openai_module.APITimeoutError = Exception
      +
      +        with patch.object(tt, "get_env_value", return_value="groq-dotenv-key"), \
      +             patch.object(tt, "_HAS_OPENAI", True), \
      +             patch.dict("sys.modules", {"openai": fake_openai_module}), \
      +             patch("builtins.open", MagicMock()):
      +            result = tt._transcribe_groq("/tmp/fake.mp3", "whisper-large-v3-turbo")
      +
      +        assert result["success"] is True
      +        assert seen_keys == ["groq-dotenv-key"]
      +
      +    def test_transcribe_mistral_forwards_dotenv_key(self):
      +        from tools import transcription_tools as tt
      +
      +        seen_keys: list = []
      +
      +        class FakeMistralClient:
      +            def __init__(self, *, api_key=[redacted:secret-assignment]
      +                seen_keys.append(api_key)
      +                self.audio = MagicMock()
      +                completion = MagicMock()
      +                completion.text = "hi"
      +                self.audio.transcriptions.complete.return_value = completion
      +            def __enter__(self): return self
      +            def __exit__(self, *a): return False
      +
      +        fake_client_module = MagicMock()
      +        fake_client_module.Mistral = FakeMistralClient
      +
      +        with patch.object(tt, "get_env_value", return_value="mistral-dotenv-key"), \
      +             patch.dict("sys.modules", {"mistralai.client": fake_client_module}), \
      +             patch("builtins.open", MagicMock()):
      +            result = tt._transcribe_mistral("/tmp/fake.mp3", "voxtral-mini-latest")
      +
      +        assert result["success"] is True
      +        assert seen_keys == ["mistral-dotenv-key"]
      +
      +    def test_transcribe_xai_forwards_dotenv_key(self):
      +        from tools import transcription_tools as tt
      +
      +        captured: dict = {}
      +
      +        def fake_post(url, **kwargs):
      +            captured["url"] = url
      +            captured["headers"] = kwargs.get("headers", {})
      +            response = MagicMock()
      +            response.status_code = 200
      +            response.raise_for_status = MagicMock()
      +            response.json.return_value = {"text": "hello"}
      +            return response
      +
      +        # get_env_value is consulted for both XAI_API_KEY and XAI_STT_BASE_URL.
      +        # Return the key for the first call, None for base-url override
      +        # (so it defaults to the module-level XAI_STT_BASE_URL).
      +        def fake_get_env_value(name, default=None):
      +            if name == "XAI_API_KEY":
      +                return "xai-dotenv-key"
      +            return None
      +
      +        with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \
      +             patch("requests.post", side_effect=fake_post), \
      +             patch("builtins.open", MagicMock()):
      +            result = tt._transcribe_xai("/tmp/fake.mp3", "grok-stt")
      +
      +        assert result["success"] is True
      +        assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key"
      +
      +
      +class TestEndToEndRegressionGuard:
      +    """End-to-end probe: patch ``hermes_cli.config.load_env`` to simulate
      +    ``~/.hermes/.env`` carrying the key while ``os.environ`` does not.
      +    Before the fix ``_transcribe_xai`` called ``os.getenv("XAI_API_KEY")``
      +    directly and returned ``XAI_API_KEY not set``."""
      +
      +    def test_xai_key_only_in_dotenv_before_fix(self, monkeypatch):
      +        from tools import transcription_tools as tt
      +
      +        monkeypatch.delenv("XAI_API_KEY", raising=False)
      +
      +        captured: dict = {}
      +
      +        def fake_post(url, **kwargs):
      +            captured["headers"] = kwargs.get("headers", {})
      +            response = MagicMock()
      +            response.status_code = 200
      +            response.raise_for_status = MagicMock()
      +            response.json.return_value = {"text": "ok"}
      +            return response
      +
      +        with patch("hermes_cli.config.load_env",
      +                   return_value={"XAI_API_KEY": "[redacted:secret-assignment]"}):
      +            # Sanity: get_env_value resolves through load_env when
      +            # os.environ is empty.
      +            from hermes_cli.config import get_env_value as live_get
      +            assert live_get("XAI_API_KEY") == "dotenv-secret"
      +
      +            with patch("requests.post", side_effect=fake_post), \
      +                 patch("builtins.open", MagicMock()):
      +                result = tt._transcribe_xai("/tmp/fake.mp3", "grok-stt")
      +
      +        assert result["success"] is True
      +        assert captured["headers"]["Authorization"] == "Bearer dotenv-secret"
    • tests/tools/test_tts_dotenv_fallback.py
      @@ -0,0 +1,233 @@
      +"""Regression tests for #17140.
      +
      +TTS provider tools must resolve API keys from ``~/.hermes/.env`` (via
      +``hermes_cli.config.get_env_value``) and not only from ``os.environ`` —
      +otherwise users who keep their keys in the dotenv file see "API key not set"
      +errors even though the key is configured. Same class of bug as #15914 (auth)
      +already addressed for ``agent/credential_pool`` and ``hermes_cli/auth``.
      +"""
      +
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +
      [redacted:email](autouse=True)
      +def isolate_env(monkeypatch):
      +    """Strip every TTS-related env var so the test really exercises the
      +    dotenv code path. If any of these survive into the test, the assertion
      +    that ``get_env_value`` was consulted becomes meaningless because
      +    ``os.environ`` already satisfies the lookup.
      +    """
      +    for key in (
      +        "ELEVENLABS_API_KEY",
      +        "XAI_API_KEY",
      +        "XAI_BASE_URL",
      +        "MINIMAX_API_KEY",
      +        "MISTRAL_API_KEY",
      +        "GEMINI_API_KEY",
      +        "GEMINI_BASE_URL",
      +        "GOOGLE_API_KEY",
      +    ):
      +        monkeypatch.delenv(key, raising=False)
      +
      +
      +class TestDotenvFallbackPerProvider:
      +    """For each affected provider, when only ``~/.hermes/.env`` carries the
      +    key, the provider must find it. These per-provider tests model that
      +    dotenv-backed lookup by mocking ``tools.tts_tool.get_env_value`` directly;
      +    the separate regression-guard tests cover the lower-level
      +    ``hermes_cli.config.load_env`` integration. Before the fix, ``os.getenv``
      +    returned ``None`` and the provider raised
      +    ``ValueError("X_API_KEY not set")``.
      +    """
      +
      +    def test_elevenlabs_reads_dotenv_key(self, tmp_path):
      +        from tools import tts_tool
      +
      +        with patch.object(tts_tool, "get_env_value", return_value="el-dotenv-key"), \
      +             patch.object(tts_tool, "_import_elevenlabs") as mock_import:
      +            mock_client = MagicMock()
      +            mock_client.text_to_speech.convert.return_value = iter([b"audio"])
      +            mock_import.return_value = MagicMock(return_value=mock_client)
      +
      +            output = str(tmp_path / "out.mp3")
      +            tts_tool._generate_elevenlabs("hi", output, {})
      +
      +            mock_import.return_value.assert_called_once_with(api_key="[redacted:secret-assignment]")
      +
      +    def test_xai_reads_dotenv_key(self, tmp_path):
      +        from tools import tts_tool
      +
      +        captured: dict = {}
      +
      +        def fake_post(url, **kwargs):
      +            captured["url"] = url
      +            captured["headers"] = kwargs.get("headers", {})
      +            response = MagicMock()
      +            response.content = b"audio"
      +            response.raise_for_status = MagicMock()
      +            return response
      +
      +        with patch.object(tts_tool, "get_env_value", return_value="xai-dotenv-key"), \
      +             patch("requests.post", side_effect=fake_post):
      +            tts_tool._generate_xai_tts("hi", str(tmp_path / "out.mp3"), {})
      +
      +        assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key"
      +
      +    def test_minimax_reads_dotenv_key(self, tmp_path):
      +        from tools import tts_tool
      +
      +        captured: dict = {}
      +
      +        def fake_post(url, **kwargs):
      +            captured["headers"] = kwargs.get("headers", {})
      +            response = MagicMock()
      +            response.json.return_value = {
      +                "data": {"audio": b"\x00\x01".hex()},
      +                "base_resp": {"status_code": 0},
      +            }
      +            response.raise_for_status = MagicMock()
      +            return response
      +
      +        with patch.object(tts_tool, "get_env_value", return_value="mm-dotenv-key"), \
      +             patch("requests.post", side_effect=fake_post):
      +            tts_tool._generate_minimax_tts("hi", str(tmp_path / "out.mp3"), {})
      +
      +        assert captured["headers"]["Authorization"] == "Bearer mm-dotenv-key"
      +
      +    def test_mistral_reads_dotenv_key(self, tmp_path):
      +        import base64
      +
      +        from tools import tts_tool
      +
      +        seen_keys: list = []
      +
      +        def fake_mistral_factory(*, api_key=[redacted:secret-assignment]
      +            seen_keys.append(api_key)
      +            client = MagicMock()
      +            client.__enter__ = MagicMock(return_value=client)
      +            client.__exit__ = MagicMock(return_value=False)
      +            client.audio.speech.complete.return_value = MagicMock(
      +                audio_data=base64.b64encode(b"data").decode()
      +            )
      +            return client
      +
      +        with patch.object(tts_tool, "get_env_value", return_value="mistral-dotenv-key"), \
      +             patch.object(tts_tool, "_import_mistral_client", return_value=fake_mistral_factory):
      +            tts_tool._generate_mistral_tts("hi", str(tmp_path / "out.mp3"), {})
      +
      +        assert seen_keys == ["mistral-dotenv-key"]
      +
      +    def test_gemini_reads_dotenv_key(self, tmp_path):
      +        from tools import tts_tool
      +
      +        captured: dict = {}
      +
      +        def fake_post(url, **kwargs):
      +            captured["params"] = kwargs.get("params", {})
      +            response = MagicMock()
      +            response.status_code = 200
      +            response.json.return_value = {
      +                "candidates": [
      +                    {
      +                        "content": {
      +                            "parts": [
      +                                {
      +                                    "inlineData": {
      +                                        "data": "AAAA",
      +                                        "mimeType": "audio/L16;codec=pcm;rate=24000",
      +                                    }
      +                                }
      +                            ]
      +                        }
      +                    }
      +                ]
      +            }
      +            response.raise_for_status = MagicMock()
      +            return response
      +
      +        # GEMINI_API_KEY hits the first branch; GOOGLE_API_KEY would only be
      +        # consulted if the first returned None. Use a side-effect-style mock
      +        # to verify the lookup order matches the production code.
      +        seen_lookups: list = []
      +
      +        def fake_get_env_value(key):
      +            seen_lookups.append(key)
      +            if key == "GEMINI_API_KEY":
      +                return "gemini-dotenv-key"
      +            return None
      +
      +        with patch.object(tts_tool, "get_env_value", side_effect=fake_get_env_value), \
      +             patch("requests.post", side_effect=fake_post):
      +            tts_tool._generate_gemini_tts("hi", str(tmp_path / "out.wav"), {})
      +
      +        assert "GEMINI_API_KEY" in seen_lookups
      +        assert captured["params"]["key"] == "gemini-dotenv-key"
      +
      +
      +class TestRegressionGuard:
      +    """Goal-backward proof that the old behaviour ('only check ``os.environ``')
      +    breaks reading from a dotenv-only key, and the new behaviour fixes it.
      +    Implemented as an end-to-end probe that patches
      +    ``hermes_cli.config.load_env`` to simulate ``~/.hermes/.env`` carrying the
      +    key while ``os.environ`` does not.
      +    """
      +
      +    def test_minimax_missing_when_only_in_dotenv_before_fix(self, tmp_path, monkeypatch):
      +        from tools import tts_tool
      +
      +        monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
      +
      +        # Simulate ~/.hermes/.env carrying the key (load_env returns the dict
      +        # that get_env_value falls back to). The pre-fix ``os.getenv`` call
      +        # ignores this entirely and raises ValueError.
      +        with patch(
      +            "hermes_cli.config.load_env",
      +            return_value={"MINIMAX_API_KEY": "[redacted:secret-assignment]"},
      +        ):
      +            # Sanity-check: get_env_value resolves through load_env when
      +            # os.environ is empty.
      +            from hermes_cli.config import get_env_value as live_get
      +            assert live_get("MINIMAX_API_KEY") == "dotenv-secret"
      +
      +            # And the production code path now consumes the resolved value
      +            # instead of raising "MINIMAX_API_KEY not set".
      +            captured: dict = {}
      +
      +            def fake_post(url, **kwargs):
      +                captured["headers"] = kwargs.get("headers", {})
      +                response = MagicMock()
      +                response.json.return_value = {
      +                    "data": {"audio": b"\x00".hex()},
      +                    "base_resp": {"status_code": 0},
      +                }
      +                response.raise_for_status = MagicMock()
      +                return response
      +
      +            with patch("requests.post", side_effect=fake_post):
      +                tts_tool._generate_minimax_tts(
      +                    "hi", str(tmp_path / "out.mp3"), {}
      +                )
      +
      +            assert captured["headers"]["Authorization"] == "Bearer dotenv-secret"
      +
      +    def test_check_tts_requirements_sees_dotenv_minimax(self, monkeypatch):
      +        """``check_tts_requirements`` is the gate that decides whether
      +        ``/voice on`` is even offered. If it only checked ``os.environ`` it
      +        would say "no provider available" for users who keep MINIMAX_API_KEY
      +        in ``~/.hermes/.env``, even though the dispatcher would later succeed.
      +        """
      +        from tools import tts_tool
      +
      +        monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
      +
      +        with patch(
      +            "hermes_cli.config.load_env",
      +            return_value={"MINIMAX_API_KEY": "[redacted:secret-assignment]"},
      +        ), patch.object(tts_tool, "_import_edge_tts", side_effect=ImportError), \
      +             patch.object(tts_tool, "_import_elevenlabs", side_effect=ImportError), \
      +             patch.object(tts_tool, "_import_openai_client", side_effect=ImportError), \
      +             patch.object(tts_tool, "_check_neutts_available", return_value=False), \
      +             patch.object(tts_tool, "_check_kittentts_available", return_value=False):
      +            assert tts_tool.check_tts_requirements() is True
    • tools/transcription_tools.py
      @@ -42,6 +42,12 @@
       
       logger = logging.getLogger(__name__)
       
      +try:
      +    from hermes_cli.config import get_env_value
      +except ImportError:
      +    def get_env_value(name, default=None):
      +        return os.getenv(name, default)
      +
       # ---------------------------------------------------------------------------
       # Optional imports — graceful degradation
       # ---------------------------------------------------------------------------
      @@ -222,7 +228,7 @@ def _get_provider(stt_config: dict) -> str:
                   return "none"
       
               if provider == "groq":
      -            if _HAS_OPENAI and os.getenv("GROQ_API_KEY"):
      +            if _HAS_OPENAI and get_env_value("GROQ_API_KEY"):
                       return "groq"
                   logger.warning(
                       "STT provider 'groq' configured but GROQ_API_KEY not set"
      @@ -238,7 +244,7 @@ def _get_provider(stt_config: dict) -> str:
                   return "none"
       
               if provider == "mistral":
      -            if _HAS_MISTRAL and os.getenv("MISTRAL_API_KEY"):
      +            if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
                       return "mistral"
                   logger.warning(
                       "STT provider 'mistral' configured but mistralai package "
      @@ -247,7 +253,7 @@ def _get_provider(stt_config: dict) -> str:
                   return "none"
       
               if provider == "xai":
      -            if os.getenv("XAI_API_KEY"):
      +            if get_env_value("XAI_API_KEY"):
                       return "xai"
                   logger.warning(
                       "STT provider 'xai' configured but XAI_API_KEY not set"
      @@ -262,16 +268,16 @@ def _get_provider(stt_config: dict) -> str:
               return "local"
           if _has_local_command():
               return "local_command"
      -    if _HAS_OPENAI and os.getenv("GROQ_API_KEY"):
      +    if _HAS_OPENAI and get_env_value("GROQ_API_KEY"):
               logger.info("No local STT available, using Groq Whisper API")
               return "groq"
           if _HAS_OPENAI and _has_openai_audio_backend():
               logger.info("No local STT available, using OpenAI Whisper API")
               return "openai"
      -    if _HAS_MISTRAL and os.getenv("MISTRAL_API_KEY"):
      +    if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
               logger.info("No local STT available, using Mistral Voxtral Transcribe API")
               return "mistral"
      -    if os.getenv("XAI_API_KEY"):
      +    if get_env_value("XAI_API_KEY"):
               logger.info("No local STT available, using xAI Grok STT API")
               return "xai"
           return "none"
      @@ -527,7 +533,7 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
       
       def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
           """Transcribe using Groq Whisper API (free tier available)."""
      -    api_key = os.getenv("GROQ_API_KEY")
      +    api_key = get_env_value("GROQ_API_KEY")
           if not api_key:
               return {"success": False, "transcript": "", "error": "GROQ_API_KEY not set"}
       
      @@ -640,7 +646,7 @@ def _transcribe_mistral(file_path: str, model_name: str) -> Dict[str, Any]:
           Uses the ``mistralai`` Python SDK to call ``/v1/audio/transcriptions``.
           Requires ``MISTRAL_API_KEY`` environment variable.
           """
      -    api_key = os.getenv("MISTRAL_API_KEY")
      +    api_key = get_env_value("MISTRAL_API_KEY")
           if not api_key:
               return {"success": False, "transcript": "", "error": "MISTRAL_API_KEY not set"}
       
      @@ -680,7 +686,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]:
           Supports Inverse Text Normalization, diarization, and word-level timestamps.
           Requires ``XAI_API_KEY`` environment variable.
           """
      -    api_key = os.getenv("XAI_API_KEY")
      +    api_key = get_env_value("XAI_API_KEY")
           if not api_key:
               return {"success": False, "transcript": "", "error": "XAI_API_KEY not set"}
       
      @@ -688,7 +694,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]:
           xai_config = stt_config.get("xai", {})
           base_url = str(
               xai_config.get("base_url")
      -        or os.getenv("XAI_STT_BASE_URL")
      +        or get_env_value("XAI_STT_BASE_URL")
               or XAI_STT_BASE_URL
           ).strip().rstrip("/")
           language = str(
    • tools/tts_tool.py
      @@ -44,6 +44,11 @@
       from hermes_constants import display_hermes_home
       
       logger = logging.getLogger(__name__)
      +try:
      +    from hermes_cli.config import get_env_value
      +except ImportError:
      +    def get_env_value(name, default=None):
      +        return os.getenv(name, default)
       from tools.managed_tool_gateway import resolve_managed_tool_gateway
       from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway, resolve_openai_audio_api_key
       from tools.xai_http import hermes_xai_user_agent
      @@ -312,7 +317,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any]
           Returns:
               Path to the saved audio file.
           """
      -    api_key = os.getenv("ELEVENLABS_API_KEY", "")
      +    api_key = [redacted:secret-assignment]"ELEVENLABS_API_KEY") or "")
           if not api_key:
               raise ValueError("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/")
       
      @@ -406,7 +411,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
           """
           import requests
       
      -    api_key = os.getenv("XAI_API_KEY", "").strip()
      +    api_key = [redacted:secret-assignment]"XAI_API_KEY") or "").strip()
           if not api_key:
               raise ValueError("XAI_API_KEY not set. Get one at https://console.x.ai/")
       
      @@ -417,7 +422,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
           bit_rate = int(xai_config.get("bit_rate", DEFAULT_XAI_BIT_RATE))
           base_url = str(
               xai_config.get("base_url")
      -        or os.getenv("XAI_BASE_URL")
      +        or get_env_value("XAI_BASE_URL")
               or DEFAULT_XAI_BASE_URL
           ).strip().rstrip("/")
       
      @@ -479,7 +484,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
           """
           import requests
       
      -    api_key = os.getenv("MINIMAX_API_KEY", "")
      +    api_key = [redacted:secret-assignment]"MINIMAX_API_KEY") or "")
           if not api_key:
               raise ValueError("MINIMAX_API_KEY not set. Get one at https://platform.minimax.io/")
       
      @@ -556,7 +561,7 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any
           and writes the raw bytes to *output_path*.
           Supports native Opus output for Telegram voice bubbles.
           """
      -    api_key = os.getenv("MISTRAL_API_KEY", "")
      +    api_key = [redacted:secret-assignment]"MISTRAL_API_KEY") or "")
           if not api_key:
               raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/")
       
      @@ -651,7 +656,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
           """
           import requests
       
      -    api_key = [redacted:secret-assignment]"GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip()
      +    api_key = [redacted:secret-assignment]"GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY") or "").strip()
           if not api_key:
               raise ValueError(
                   "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey"
      @@ -662,7 +667,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
           voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE
           base_url = str(
               gemini_config.get("base_url")
      -        or os.getenv("GEMINI_BASE_URL")
      +        or get_env_value("GEMINI_BASE_URL")
               or DEFAULT_GEMINI_TTS_BASE_URL
           ).strip().rstrip("/")
       
      @@ -1148,7 +1153,7 @@ def check_tts_requirements() -> bool:
               pass
           try:
               _import_elevenlabs()
      -        if os.getenv("ELEVENLABS_API_KEY"):
      +        if get_env_value("ELEVENLABS_API_KEY"):
                   return True
           except ImportError:
               pass
      @@ -1158,15 +1163,15 @@ def check_tts_requirements() -> bool:
                   return True
           except ImportError:
               pass
      -    if os.getenv("MINIMAX_API_KEY"):
      +    if get_env_value("MINIMAX_API_KEY"):
               return True
      -    if os.getenv("XAI_API_KEY"):
      +    if get_env_value("XAI_API_KEY"):
               return True
      -    if os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"):
      +    if get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY"):
               return True
           try:
               _import_mistral_client()
      -        if os.getenv("MISTRAL_API_KEY"):
      +        if get_env_value("MISTRAL_API_KEY"):
                   return True
           except ImportError:
               pass
      @@ -1278,7 +1283,7 @@ def stream_tts_to_speaker(
                   {**tts_config, "elevenlabs": {**el_config, "model_id": model_id}},
               )
       
      -        api_key = os.getenv("ELEVENLABS_API_KEY", "")
      +        api_key = [redacted:secret-assignment]"ELEVENLABS_API_KEY") or "")
               if not api_key:
                   logger.warning("ELEVENLABS_API_KEY not set; streaming TTS audio disabled")
               else:
      @@ -1464,13 +1469,13 @@ def _check(importer, label):
           print("\nProvider availability:")
           print(f"  Edge TTS:   {'installed' if _check(_import_edge_tts, 'edge') else 'not installed (pip install edge-tts)'}")
           print(f"  ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}")
      -    print(f"    API Key:  {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}")
      +    print(f"    API Key:  {'set' if get_env_value('ELEVENLABS_API_KEY') else 'not set'}")
           print(f"  OpenAI:     {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}")
           print(
               "    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 os.getenv('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}")
      +    print(f"  MiniMax:    {'API key set' if get_env_value('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}")
           print(f"  ffmpeg:     {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}")
           print(f"\n  Output dir: {DEFAULT_OUTPUT_DIR}")
       

2 PRs closed without merging

From the close comments: duplicate 1 · merged via another change 1

  • #17163 fix(tts): resolve API keys from ~/.hermes/.env via get_env_value (#17140) · merged via another change → #17434
  • #17262 fix(tts): use get_env_value for API key resolution from ~/.hermes/.env · duplicate → #17163
Closed issues · 1
  • Closed#17140[Bug]: TTS tools fail to read API keys from ~/.hermes/.env — os.getenv() doesn't see dotenv values
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#17434
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
29 Apr 2026

For your agent

The same facts, typed.