# NousResearch/hermes-agent · TTS/STT API key loading from ~/.hermes/.env

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #17434. Verify it addresses your case.
- **Recommended:** read PR #17434 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-17434`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #17140: \[Bug\]: TTS tools fail to read API keys from ~/.hermes/.env — os.getenv() doesn't see dotenv values

## Prior & competing PRs

- **#17434: fix(voice): resolve API keys from ~/.hermes/.env in TTS + STT tools (#17140)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `tests/tools/test_transcription_dotenv_fallback.py`, `tests/tools/test_tts_dotenv_fallback.py`, `tools/transcription_tools.py`, `tools/tts_tool.py`
  - fix hunks:
  - `tests/tools/test_transcription_dotenv_fallback.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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 "
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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"}
     
    ```

    ```diff
    @@ -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"}
     
    ```

    ```diff
    @@ -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"}
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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/")
     
    ```

    ```diff
    @@ -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/")
     
    ```

    ```diff
    @@ -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("/")
     
    ```

    ```diff
    @@ -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/")
     
    ```

    ```diff
    @@ -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/")
     
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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("/")
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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}")
     
    ```
- **#17163: fix(tts): resolve API keys from ~/.hermes/.env via get_env_value (#17140)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#17434`
- **#17262: fix(tts): use get_env_value for API key resolution from ~/.hermes/.env**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#17163`

## Fix status

- Merged to main in PR #17434 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-29`.
