diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 73e7a42a59b..a28c777a8f1 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -170,7 +170,15 @@ class TestTranscribeCallSitesReadDotenv: assert seen_keys == ["mistral-dotenv-key"] def test_transcribe_xai_forwards_dotenv_key(self): + """xAI STT now resolves credentials through ``tools.xai_http`` so the + OAuth bearer wins when present and ``XAI_API_KEY`` is the fallback. + Patch the resolver's ``get_env_value`` to simulate a dotenv-only key + and confirm it reaches the HTTP call. The per-call-site + ``transcription_tools.get_env_value`` is still consulted for the + ``XAI_STT_BASE_URL`` override (covered by ``test_custom_base_url``). + """ from tools import transcription_tools as tt + from tools import xai_http captured: dict = {} @@ -183,15 +191,12 @@ class TestTranscribeCallSitesReadDotenv: 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), \ + with patch.object(xai_http, "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") diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index 05083208709..0a4ea5a8ac2 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -57,7 +57,12 @@ class TestDotenvFallbackPerProvider: mock_import.return_value.assert_called_once_with(api_key="el-dotenv-key") def test_xai_reads_dotenv_key(self, tmp_path): + """xAI TTS now resolves credentials through ``tools.xai_http``; the + dotenv fallback contract from #17140 is preserved by patching the + resolver's ``get_env_value`` rather than ``tts_tool.get_env_value``. + """ from tools import tts_tool + from tools import xai_http captured: dict = {} @@ -69,7 +74,7 @@ class TestDotenvFallbackPerProvider: response.raise_for_status = MagicMock() return response - with patch.object(tts_tool, "get_env_value", return_value="xai-dotenv-key"), \ + with patch.object(xai_http, "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"), {}) diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 6f6d2f8c2a3..d741530d358 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -726,8 +726,8 @@ 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 creds.get("base_url") or get_env_value("XAI_STT_BASE_URL") + or creds.get("base_url") or XAI_STT_BASE_URL ).strip().rstrip("/") language = str( diff --git a/tools/xai_http.py b/tools/xai_http.py index fbb7961d244..216a51ff10d 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -5,6 +5,25 @@ from __future__ import annotations import os from typing import Dict +try: + from hermes_cli.config import get_env_value as _hermes_get_env_value +except Exception: + _hermes_get_env_value = None + + +def get_env_value(name: str, default=None): + """Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``. + + Wraps :func:`hermes_cli.config.get_env_value` so tests can patch + ``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the + xAI credential resolver. + """ + if _hermes_get_env_value is not None: + value = _hermes_get_env_value(name) + if value is not None: + return value + return os.environ.get(name, default) + def hermes_xai_user_agent() -> str: """Return a stable Hermes-specific User-Agent for xAI HTTP calls.""" @@ -19,8 +38,11 @@ def resolve_xai_http_credentials() -> Dict[str, str]: """Resolve bearer credentials for direct xAI HTTP endpoints. Prefers Hermes-managed xAI OAuth credentials when available, then falls back - to ``XAI_API_KEY`` from the environment. This keeps direct xAI endpoints - (images, TTS, STT, etc.) aligned with the main runtime auth model. + to ``XAI_API_KEY`` resolved via ``hermes_cli.config.get_env_value`` so keys + stored in ``~/.hermes/.env`` (the standard Hermes location) are honored — + not just ones already exported into ``os.environ``. This keeps direct xAI + endpoints (images, TTS, STT, etc.) aligned with the main runtime auth model + and preserves the regression contract from PR #17140 / #17163. """ try: from hermes_cli.runtime_provider import resolve_runtime_provider @@ -52,8 +74,8 @@ def resolve_xai_http_credentials() -> Dict[str, str]: except Exception: pass - api_key = os.getenv("XAI_API_KEY", "").strip() - base_url = (os.getenv("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/") + api_key = str(get_env_value("XAI_API_KEY") or "").strip() + base_url = str(get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/") return { "provider": "xai", "api_key": api_key,