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