diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1f65c5e5c8..ba712f0fab 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -20,7 +20,7 @@ from agent.credential_pool import ( # custom_provider_pool_key_candidates is re CredentialPool, PooledCredential, credential_pool_matches_provider, custom_provider_pool_key_candidates, # noqa: F401 load_pool, ) -from agent.secret_scope import get_secret as _get_secret +from agent.secret_scope import get_secret_str from hermes_cli.auth import ( # resolve_external_process_provider_credentials is read via origin by runtime_provider_backends ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, AuthError, DEFAULT_CODEX_BASE_URL, DEFAULT_QWEN_BASE_URL, DEFAULT_XAI_OAUTH_BASE_URL, PROVIDER_REGISTRY, _agent_key_is_usable, _nous_inference_env_override, format_auth_error, resolve_provider, @@ -51,13 +51,6 @@ def normalize_extra_headers(value): return _config_mod.normalize_extra_headers(value) -def _getenv(name: str, default: str = "") -> str: - """Profile-scoped ``os.getenv`` for credential/provider reads: identical to ``os.getenv`` when - multiplexing is off; scope-aware (fail-closed on an unscoped read) when on.""" - val = _get_secret(name, default) - return val if val is not None else default - - def _loopback_hostname(host: str) -> bool: return (host or "").lower().rstrip(".") in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} @@ -309,7 +302,7 @@ def _host_derived_api_key(base_url: str) -> str: sanitized = "".join(ch if ch.isalnum() else "_" for ch in labels[-2]).upper() if len(labels) >= 2 else "" if not sanitized or not sanitized[0].isalpha() or sanitized in ("OPENAI", "OPENROUTER", "OLLAMA"): return "" - return (_getenv(f"{sanitized}_API_KEY", "") or "").strip() + return (get_secret_str(f"{sanitized}_API_KEY", "") or "").strip() def _host_gated_env_key_candidates(base_url: str, *, ollama: bool) -> list: @@ -318,9 +311,9 @@ def _host_gated_env_key_candidates(base_url: str, *, ollama: bool) -> list: (GHSA-76xc-57q6-vm5m); match on HOST, not substring. ``_host_derived_api_key`` skips OLLAMA, so callers that want it opt in via ``ollama``.""" is_openai = base_url_host_matches(base_url, "openai.com") or base_url_host_matches(base_url, "openai.azure.com") - candidates = [_getenv("OLLAMA_API_KEY", "").strip() if base_url_host_matches(base_url, "ollama.com") else ""] if ollama else [] - return candidates + [_getenv("OPENAI_API_KEY", "").strip() if is_openai else "", - _getenv("OPENROUTER_API_KEY", "").strip() if base_url_host_matches(base_url, "openrouter.ai") else "", + candidates = [get_secret_str("OLLAMA_API_KEY", "").strip() if base_url_host_matches(base_url, "ollama.com") else ""] if ollama else [] + return candidates + [get_secret_str("OPENAI_API_KEY", "").strip() if is_openai else "", + get_secret_str("OPENROUTER_API_KEY", "").strip() if base_url_host_matches(base_url, "openrouter.ai") else "", _host_derived_api_key(base_url)] @@ -341,7 +334,7 @@ def _nous_min_key_ttl() -> int: def _resolve_nous_creds() -> Dict[str, Any]: - return resolve_nous_runtime_credentials(timeout_seconds=float(_getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15"))) + return resolve_nous_runtime_credentials(timeout_seconds=float(get_secret_str("HERMES_NOUS_TIMEOUT_SECONDS", "15"))) def _finalize_base_url(provider: str, api_mode: str, base_url: str) -> str: @@ -414,7 +407,7 @@ def resolve_requested_provider(requested: Optional[str] = None) -> str: cfg_provider = _get_model_config().get("provider") if isinstance(cfg_provider, str) and cfg_provider.strip(): return cfg_provider.strip().lower() - return _getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower() or "auto" + return get_secret_str("HERMES_INFERENCE_PROVIDER", "").strip().lower() or "auto" # ── extracted collaborators (re-exported; see module docstring) ──────────────────────────── @@ -490,7 +483,7 @@ def _resolve_runtime_from_pool_entry(*, provider: str, entry: PooledCredential, def _openrouter_should_use_pool(requested_provider, model_cfg, explicit_api_key, explicit_base_url) -> bool: """OpenRouter pool only for a plain openrouter/auto request with no custom endpoint or override.""" cfg_base_url = str(model_cfg.get("base_url") or "").strip() - env_base_urls = _getenv("OPENAI_BASE_URL", "").strip() or _getenv("OPENROUTER_BASE_URL", "").strip() + env_base_urls = get_secret_str("OPENAI_BASE_URL", "").strip() or get_secret_str("OPENROUTER_BASE_URL", "").strip() # A config base_url under provider: openrouter is a mirror only when it is NOT the canonical # OpenRouter host — `hermes setup` persists https://openrouter.ai/api/v1 for plain installs, # and treating that as custom would drop the auth.json pool (empty key). @@ -602,7 +595,7 @@ def _explicit_api_key_provider(provider, pconfig, requested_provider, model_cfg, elif provider in {"kimi-coding", "kimi-coding-cn"}: base_url = resolve_api_key_provider_credentials(provider).get("base_url", "").rstrip("/") else: - env_url = _getenv(pconfig.base_url_env_var, "").strip().rstrip("/") if pconfig.base_url_env_var else "" + env_url = get_secret_str(pconfig.base_url_env_var, "").strip().rstrip("/") if pconfig.base_url_env_var else "" base_url = env_url or pconfig.inference_base_url base_url = _actual_url(provider, base_url) if not api_key: @@ -705,10 +698,10 @@ def _azure_anthropic_env_key(model_cfg: Dict[str, Any]) -> str: api_key (multi-profile setups), then the historical fixed names.""" for hint_key in ("key_env", "api_key_env"): env_var = str(model_cfg.get(hint_key) or "").strip() - if env_var and (token := _getenv(env_var, "").strip()): + if env_var and (token := get_secret_str(env_var, "").strip()): return token - return (str(model_cfg.get("api_key") or "").strip() or _getenv("AZURE_ANTHROPIC_KEY", "").strip() - or _getenv("ANTHROPIC_API_KEY", "").strip()) + return (str(model_cfg.get("api_key") or "").strip() or get_secret_str("AZURE_ANTHROPIC_KEY", "").strip() + or get_secret_str("ANTHROPIC_API_KEY", "").strip()) def _anthropic_env_runtime(requested_provider: str, model_cfg: Dict[str, Any]) -> Dict[str, Any]: diff --git a/hermes_cli/runtime_provider_backends.py b/hermes_cli/runtime_provider_backends.py index 17806bfced..6f786ca3f2 100644 --- a/hermes_cli/runtime_provider_backends.py +++ b/hermes_cli/runtime_provider_backends.py @@ -10,6 +10,7 @@ import os import re from typing import Any, Dict, Optional +from agent.secret_scope import get_secret_str from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches @@ -49,7 +50,7 @@ def _azure_foundry_api_key(rp, explicit_api_key: str) -> str: api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or "" except Exception: api_key = "" - api_key = api_key or rp._getenv("AZURE_FOUNDRY_API_KEY", "").strip() + api_key = api_key or get_secret_str("AZURE_FOUNDRY_API_KEY", "").strip() if not api_key: raise rp.AuthError( "Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in " @@ -80,7 +81,7 @@ def _resolve_azure_foundry_runtime(*, requested_provider: str, model_cfg: Dict[s # GPT-5.x / codex / o1-o4 deployments are Responses-API-only on Foundry. effective_model = str(target_model or model_cfg.get("default") or "").strip() cfg_api_mode = rp._azure_inferred_api_mode(effective_model, cfg_api_mode) - env_base_url = rp._getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/") + env_base_url = get_secret_str("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/") base_url = explicit_base_url_clean or cfg_base_url or env_base_url if not base_url: raise rp.AuthError( @@ -126,8 +127,8 @@ def _resolve_openrouter_runtime( # Aliases resolving to "custom" (ollama, vllm, …) follow bare-custom trust + routing rules. if requested_norm and requested_norm != "custom" and rp._resolves_to_custom(requested_norm): requested_norm = "custom" - env_openrouter_base_url = rp._getenv("OPENROUTER_BASE_URL", "").strip() - env_custom_base_url = rp._getenv("CUSTOM_BASE_URL", "").strip() + env_openrouter_base_url = get_secret_str("OPENROUTER_BASE_URL", "").strip() + env_custom_base_url = get_secret_str("CUSTOM_BASE_URL", "").strip() use_config_base_url = bool(cfg_base_url.strip()) and not explicit_base_url and ( (requested_norm == "auto" and cfg_provider in ("", "auto")) or (requested_norm == "custom" and rp._config_base_url_trustworthy_for_bare_custom(cfg_base_url, cfg_provider)) @@ -152,7 +153,7 @@ def _resolve_openrouter_runtime( ) ) if is_openrouter_context: - candidates = [explicit_api_key, rp._getenv("OPENROUTER_API_KEY"), rp._getenv("OPENAI_API_KEY")] + candidates = [explicit_api_key, get_secret_str("OPENROUTER_API_KEY"), get_secret_str("OPENAI_API_KEY")] else: candidates = [explicit_api_key, (cfg_api_key if use_config_base_url else ""), *rp._host_gated_env_key_candidates(base_url, ollama=True)] diff --git a/hermes_cli/runtime_provider_custom.py b/hermes_cli/runtime_provider_custom.py index b6783924a0..9b4219d7e2 100644 --- a/hermes_cli/runtime_provider_custom.py +++ b/hermes_cli/runtime_provider_custom.py @@ -12,6 +12,7 @@ import os from typing import Any, Callable, Dict, Optional from hermes_cli.providers import custom_provider_aliases, custom_provider_slug +from agent.secret_scope import get_secret_str from utils import base_url_hostname logger = logging.getLogger("hermes_cli.runtime_provider") @@ -116,9 +117,9 @@ def _match_new_style_provider(requested_norm: str, providers: Dict[str, Any]) -> if not isinstance(entry, dict) or not is_provider_enabled(entry): continue # API key from the env var named by key_env, else the inline api_key. Read BEFORE the - # alias match (scope-aware ``_getenv`` fails closed identically for every entry). + # alias match (scope-aware ``get_secret_str`` fails closed identically for every entry). key_env = _clean(entry.get("key_env") or entry.get("api_key_env")) - api_key = rp._getenv(key_env, "").strip() if key_env else "" + api_key = get_secret_str(key_env, "").strip() if key_env else "" if requested_norm not in custom_provider_aliases(str(entry.get("name", "") or ep_name), str(ep_name)): continue base_url = _entry_url(entry) @@ -487,7 +488,7 @@ def _resolve_named_custom_runtime(*, requested_provider: str, explicit_api_key: candidates = [ explicit_key, _clean(custom_provider.get("api_key", "")), - rp._getenv(_clean(custom_provider.get("key_env", "")), "").strip(), + get_secret_str(_clean(custom_provider.get("key_env", "")), "").strip(), *rp._host_gated_env_key_candidates(base_url, ollama=False), ] api_key: Any = next((c for c in candidates if rp.has_usable_secret(c)), "") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9294610b40..afc8fcf26a 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -109,12 +109,8 @@ def _xai_credentials_present() -> bool: return True except Exception: pass - try: - from tools.xai_http import get_env_value as _xai_get_env_value - if str(_xai_get_env_value("XAI_API_KEY") or "").strip(): - return True - except Exception: - pass + if str(get_env_value("XAI_API_KEY") or "").strip(): + return True try: from agent.secret_scope import get_secret except ImportError: # pragma: no cover — secret_scope is in-repo diff --git a/tests/gateway/test_multiplex_credential_isolation.py b/tests/gateway/test_multiplex_credential_isolation.py index fe6233525e..e176d19e71 100644 --- a/tests/gateway/test_multiplex_credential_isolation.py +++ b/tests/gateway/test_multiplex_credential_isolation.py @@ -20,11 +20,10 @@ def _reset(monkeypatch): class TestRuntimeProviderUsesScope: - """hermes_cli.runtime_provider._getenv resolves through the secret scope.""" - + """runtime_provider's credential reads (agent.secret_scope.get_secret_str) resolve through the scope.""" def test_getenv_two_profiles_isolated(self, monkeypatch): - from hermes_cli.runtime_provider import _getenv + from agent.secret_scope import get_secret_str as _getenv ss.set_multiplex_active(True) tok_a = ss.set_secret_scope({"OPENAI_API_KEY": "sk-A"}) diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 6b92494ada..c0fa153da1 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -41,56 +41,6 @@ class TestProviderSelectionGate: configure ``{"enabled": True, "provider": ...}`` for explicit tests. """ - def test_import_after_config_env_patch_uses_restored_dotenv_loader(self): - """Importing STT while hermes_cli.config.get_env_value is patched must - not freeze that temporary helper into this module forever. - """ - import importlib - import hermes_cli.config as config_mod - from tools import transcription_tools as tt - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(config_mod, "get_env_value", lambda name, default=None: "") - tt = importlib.reload(tt) - - try: - 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": "dotenv-secret"}): - assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq" - finally: - importlib.reload(tt) - - def test_xai_resolver_import_after_config_env_patch_uses_restored_dotenv_loader(self): - """xAI HTTP auth must not cache a temporarily patched env helper.""" - import importlib - import hermes_cli.config as config_mod - from tools import xai_http - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(config_mod, "get_env_value", lambda name, default=None: "") - xai_http = importlib.reload(xai_http) - - try: - with patch( - "hermes_cli.runtime_provider.resolve_runtime_provider", - side_effect=RuntimeError("no oauth"), - ), patch( - "hermes_cli.auth.resolve_xai_oauth_runtime_credentials", - return_value={}, - ), patch( - "hermes_cli.config.load_env", - return_value={"XAI_API_KEY": "dotenv-secret"}, - ): - creds = xai_http.resolve_xai_http_credentials() - finally: - importlib.reload(xai_http) - - assert creds["api_key"] == "dotenv-secret" - - 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 @@ -132,7 +82,7 @@ class TestTranscribeCallSitesReadDotenv: fake_openai_module.APIConnectionError = Exception fake_openai_module.APITimeoutError = Exception - with patch.object(tt, "get_env_value", return_value="groq-dotenv-key"), \ + with patch("hermes_cli.config.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()): @@ -163,7 +113,7 @@ class TestTranscribeCallSitesReadDotenv: return "xai-dotenv-key" return None - with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \ + with patch("hermes_cli.config.get_env_value", side_effect=fake_get_env_value), \ patch.object(xai_http, "resolve_xai_http_credentials", return_value={ "provider": "xai-oauth", "api_key": "subscription-oauth-token", @@ -194,7 +144,7 @@ class TestTranscribeCallSitesReadDotenv: return "elevenlabs-dotenv-key" return None - with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \ + with patch("hermes_cli.config.get_env_value", side_effect=fake_get_env_value), \ patch.object(tt, "_load_stt_config", return_value={}), \ patch("requests.post", side_effect=fake_post), \ patch("builtins.open", MagicMock()): diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index f702e4e8c2..72e6f91ed0 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -35,7 +35,7 @@ def isolate_env(monkeypatch): 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; + dotenv-backed lookup by mocking ``hermes_cli.config.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 @@ -45,7 +45,7 @@ class TestDotenvFallbackPerProvider: 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"), \ + with patch("hermes_cli.config.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"]) @@ -57,12 +57,10 @@ 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``. + """xAI TTS resolves credentials through ``tools.xai_http``, which reads the + canonical ``hermes_cli.config.get_env_value`` — the dotenv contract from #17140. """ from tools import tts_tool - from tools import xai_http captured: dict = {} @@ -74,7 +72,7 @@ class TestDotenvFallbackPerProvider: response.raise_for_status = MagicMock() return response - with patch.object(xai_http, "get_env_value", return_value="xai-dotenv-key"), \ + with patch("hermes_cli.config.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"), {}) @@ -120,7 +118,7 @@ class TestDotenvFallbackPerProvider: return "gemini-dotenv-key" return None - with patch.object(tts_tool, "get_env_value", side_effect=fake_get_env_value), \ + with patch("hermes_cli.config.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"), {}) @@ -136,45 +134,6 @@ class TestRegressionGuard: key while ``os.environ`` does not. """ - def test_import_after_config_env_patch_uses_restored_dotenv_loader(self, tmp_path, monkeypatch): - """Importing TTS while hermes_cli.config.get_env_value is patched must - not freeze that temporary helper into this module forever. - """ - import importlib - import hermes_cli.config as config_mod - from tools import tts_tool - - monkeypatch.delenv("MINIMAX_API_KEY", raising=False) - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(config_mod, "get_env_value", lambda name: "") - tts_tool = importlib.reload(tts_tool) - - try: - 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( - "hermes_cli.config.load_env", - return_value={"MINIMAX_API_KEY": "dotenv-secret"}, - ), 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" - finally: - importlib.reload(tts_tool) - def test_minimax_missing_when_only_in_dotenv_before_fix(self, tmp_path, monkeypatch): from tools import tts_tool diff --git a/tests/tools/test_tts_macos_output.py b/tests/tools/test_tts_macos_output.py index a612f4a54f..9fcd886a77 100644 --- a/tests/tools/test_tts_macos_output.py +++ b/tests/tools/test_tts_macos_output.py @@ -35,7 +35,7 @@ def _run_stream(monkeypatch): """ from tools.tts_tool_speaker import stream_tts_to_speaker - monkeypatch.setattr("tools.tts_tool.get_env_value", + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda name, default=None: "fake-key" if name == "ELEVENLABS_API_KEY" else default) monkeypatch.setattr("tools.tts_tool._load_tts_config", lambda: {}) diff --git a/tests/tools/test_tts_minimax_region.py b/tests/tools/test_tts_minimax_region.py index 81f6ff3330..2d7ad8fada 100644 --- a/tests/tools/test_tts_minimax_region.py +++ b/tests/tools/test_tts_minimax_region.py @@ -20,7 +20,7 @@ CN_CREDENTIAL_SENTINEL = "FAKE_CN_CREDENTIAL" def _fake_minimax_credentials(monkeypatch): values = {} monkeypatch.setattr( - "tools.tts_tool.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: values.get(name, default), ) return values diff --git a/tests/tools/test_tts_provider_base_urls.py b/tests/tools/test_tts_provider_base_urls.py index d9f154d20d..16764854e5 100644 --- a/tests/tools/test_tts_provider_base_urls.py +++ b/tests/tools/test_tts_provider_base_urls.py @@ -62,7 +62,7 @@ def test_mistral_no_base_url_omits_server_url(tmp_path): out = tmp_path / "out.mp3" with patch.object(tts, "_import_mistral_client", return_value=_FakeMistral), \ - patch.object(tts, "get_env_value", lambda k, *a: "key" if k == "MISTRAL_API_KEY" else None): + patch("hermes_cli.config.get_env_value", lambda k, *a: "key" if k == "MISTRAL_API_KEY" else None): tts._generate_mistral_tts("hi", str(out), {"mistral": {}}) assert "server_url" not in captured diff --git a/tests/tools/test_tts_pythonpath_fallback.py b/tests/tools/test_tts_pythonpath_fallback.py index 39f873cf07..6b2c1e35df 100644 --- a/tests/tools/test_tts_pythonpath_fallback.py +++ b/tests/tools/test_tts_pythonpath_fallback.py @@ -139,7 +139,7 @@ class TestMistralSttPythonpathFallback: "mistralai.client": mock_mistralai, }), patch("tools.lazy_deps.ensure", side_effect=FeatureUnavailable("stt.mistral", (), "test")), \ - patch("tools.transcription_tools.get_env_value", + patch("hermes_cli.config.get_env_value", return_value="test-key"): result = _transcribe_mistral(str(audio_file), "mistral-large-latest") diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py index 2db7531a6a..75670c4999 100644 --- a/tests/tools/test_tts_streaming.py +++ b/tests/tools/test_tts_streaming.py @@ -141,7 +141,7 @@ def test_openai_streamer_prefers_configured_api_key(monkeypatch): self.audio.speech.with_streaming_response = _StreamingCreate() monkeypatch.setattr(ts, "resolve_openai_audio_api_key", lambda: "env-key") - monkeypatch.setattr(ts, "get_env_value", lambda key, *args: None) + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key, *args: None) monkeypatch.setattr("openai.OpenAI", _OpenAI) config = { diff --git a/tests/tools/test_tts_xai_speech_tags.py b/tests/tools/test_tts_xai_speech_tags.py index 57bde5034a..473d11785f 100644 --- a/tests/tools/test_tts_xai_speech_tags.py +++ b/tests/tools/test_tts_xai_speech_tags.py @@ -201,7 +201,7 @@ def test_generate_xai_tts_prefers_explicit_api_key_over_oauth(tmp_path, monkeypa ) monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr( - "tools.xai_http.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: { "XAI_API_KEY": "paid-api-key", "XAI_BASE_URL": "https://staging.x.ai/v1/", diff --git a/tests/tools/test_x_search_tool.py b/tests/tools/test_x_search_tool.py index 7f5e834a62..146f03f3a6 100644 --- a/tests/tools/test_x_search_tool.py +++ b/tests/tools/test_x_search_tool.py @@ -365,7 +365,7 @@ def test_x_search_prefers_explicit_api_key_over_oauth(monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr( - "tools.xai_http.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: { "XAI_API_KEY": paid_key, }.get(name, default), @@ -396,7 +396,7 @@ def test_x_search_bearer_helper_falls_back_to_oauth_without_api_key(monkeypatch) monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr( - "tools.xai_http.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: default, ) _install_fake_oauth_pool(monkeypatch, oauth_token) diff --git a/tests/tools/test_xai_http_credentials.py b/tests/tools/test_xai_http_credentials.py index 88444d30ba..617bf521b8 100644 --- a/tests/tools/test_xai_http_credentials.py +++ b/tests/tools/test_xai_http_credentials.py @@ -95,7 +95,7 @@ def test_prefer_api_key_wins_over_available_oauth(monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr( - "tools.xai_http.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: {"XAI_API_KEY": "paid-key-x1"}.get(name, default), ) _install_fake_oauth_pool(monkeypatch, "oauth-token-x1") @@ -118,7 +118,7 @@ def test_prefer_api_key_falls_back_to_oauth_without_explicit_key(monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr( - "tools.xai_http.get_env_value", lambda name, default=None: default + "hermes_cli.config.get_env_value", lambda name, default=None: default ) _install_fake_oauth_pool(monkeypatch, "oauth-token-x1") @@ -136,7 +136,7 @@ def test_prefer_api_key_honors_hermes_xai_base_url_with_validation(monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setattr( - "tools.xai_http.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: { "XAI_API_KEY": "paid-key-x1", "HERMES_XAI_BASE_URL": "https://staging.x.ai/v1", @@ -149,7 +149,7 @@ def test_prefer_api_key_honors_hermes_xai_base_url_with_validation(monkeypatch): assert creds["base_url"] == "https://staging.x.ai/v1" monkeypatch.setattr( - "tools.xai_http.get_env_value", + "hermes_cli.config.get_env_value", lambda name, default=None: { "XAI_API_KEY": "paid-key-x1", "XAI_BASE_URL": "https://attacker.example/v1", diff --git a/tools/transcription_cloud.py b/tools/transcription_cloud.py index 21cfbb3649..890dd38a8c 100644 --- a/tools/transcription_cloud.py +++ b/tools/transcription_cloud.py @@ -3,8 +3,8 @@ OpenAI-SDK-shaped backends (groq, openai, deepinfra), Mistral Voxtral, REST multipart backends (xAI, ElevenLabs), and OpenAI audio credential resolution (config > keyless local server > env > managed Nous gateway). Facade-owned state and helpers -(``_HAS_OPENAI``, ``_resolve_provider_key``, ``_resolve_stt_language``, ``_load_stt_config``, -``get_env_value``) are read lazily from ``tools.transcription_tools``. +(``_HAS_OPENAI``, ``_resolve_provider_key``, ``_resolve_stt_language``, ``_load_stt_config``) +are read lazily from ``tools.transcription_tools``. """ from __future__ import annotations @@ -227,7 +227,8 @@ def _transcribe_xai( file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None ) -> Dict[str, Any]: """Transcribe via xAI ``POST /v1/stt`` (multipart). Supports ITN, diarization, word timestamps.""" - from tools.transcription_tools import _load_stt_config, _resolve_stt_language, get_env_value + from hermes_cli.config import get_env_value + from tools.transcription_tools import _load_stt_config, _resolve_stt_language from tools.xai_http import resolve_xai_http_credentials if prompt: _log_prompt_unsupported("STT provider 'xai'") @@ -295,7 +296,8 @@ def _transcribe_elevenlabs( file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None ) -> Dict[str, Any]: """Transcribe using ElevenLabs Scribe STT API.""" - from tools.transcription_tools import _load_stt_config, _resolve_provider_key, _resolve_stt_language, get_env_value + from hermes_cli.config import get_env_value + from tools.transcription_tools import _load_stt_config, _resolve_provider_key, _resolve_stt_language if prompt: _log_prompt_unsupported("STT provider 'elevenlabs'") api_key = _resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs") diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 5704032bae..ff6df1b39a 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -43,23 +43,10 @@ from tools.transcription_command import ( logger = logging.getLogger(__name__) -def get_env_value(name, default=None): - """Read env values through the live config module (resolved per call: tests monkeypatch it around import).""" - try: - from hermes_cli.config import get_env_value as _get_env_value - except ImportError: - return os.getenv(name, default) - value = _get_env_value(name) - return default if value is None else value - - def _resolve_provider_key(env_var: str, provider_id: str) -> str: """STT API key via the shared voice-key resolver (config > env/.env > credential pool); resolved per call.""" - try: - from tools.tool_backend_helpers import resolve_provider_secret - except ImportError: # pragma: no cover — helpers are in-repo - return str(get_env_value(env_var) or "").strip() - return resolve_provider_secret(env_var, provider_id, env_getter=get_env_value) + from tools.tool_backend_helpers import resolve_provider_secret + return resolve_provider_secret(env_var, provider_id) def _safe_find_spec(module_name: str) -> bool: diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index 70eab8e62f..e325d62123 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -17,7 +17,7 @@ from abc import ABC, abstractmethod from typing import Callable, Dict, Iterator, List, Optional from tools.tool_backend_helpers import resolve_openai_audio_api_key -from tools.tts_tool import _get_provider, _load_tts_config, get_env_value +from tools.tts_tool import _get_provider, _load_tts_config logger = logging.getLogger(__name__) @@ -32,6 +32,7 @@ def _resolve_key(env_var: str, provider_id: str) -> str: from tools.tts_tool import _resolve_provider_key return _resolve_provider_key(env_var, provider_id) or "" except Exception: + from hermes_cli.config import get_env_value return get_env_value(env_var) or "" @@ -210,6 +211,7 @@ class OpenAIStreamer(StreamingTTSProvider): def stream(self, text: str) -> Iterator[bytes]: from openai import OpenAI + from hermes_cli.config import get_env_value client = OpenAI( api_key=(self.section.get("api_key") or resolve_openai_audio_api_key()), base_url=(self.section.get("base_url") or get_env_value("OPENAI_BASE_URL") or None)) @@ -238,6 +240,7 @@ class GeminiStreamer(StreamingTTSProvider): import requests from tools.tts_tool_providers import ( DEFAULT_GEMINI_TTS_BASE_URL, DEFAULT_GEMINI_TTS_MODEL, DEFAULT_GEMINI_TTS_VOICE) + from hermes_cli.config import get_env_value api_key = _gemini_key() model = str(self.section.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(self.section.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 925311a396..4d27cced65 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -26,23 +26,10 @@ from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) -def get_env_value(name, default=None): - """Read env values through the live config module (resolved per call so test patches apply).""" - try: - from hermes_cli.config import get_env_value as _get_env_value - except ImportError: - return os.getenv(name, default) - value = _get_env_value(name) - return default if value is None else value - - def _resolve_provider_key(env_var: str, provider_id: str) -> str: """Resolve a TTS provider API key via the shared voice-key resolver (config > env/.env > pool).""" - try: - from tools.tool_backend_helpers import resolve_provider_secret - except ImportError: # pragma: no cover — helpers are in-repo - return str(get_env_value(env_var) or "").strip() - return resolve_provider_secret(env_var, provider_id, env_getter=get_env_value) + from tools.tool_backend_helpers import resolve_provider_secret + return resolve_provider_secret(env_var, provider_id) from tools.tts_command_provider import ( diff --git a/tools/tts_tool_providers.py b/tools/tts_tool_providers.py index 09b12a02ea..4af368defa 100644 --- a/tools/tts_tool_providers.py +++ b/tools/tts_tool_providers.py @@ -3,7 +3,7 @@ Each ``_generate_(text, output_path, tts_config) -> path`` writes one final-encoded file. Shared here: bounded upstream response reading (16 MiB cap so a hostile endpoint can't feed unbounded audio) and the auxiliary-model speech-tag rewrites. OpenAI/DeepInfra live in -``tts_tool_openai``. Origin seams (``get_env_value``, ``_resolve_provider_key``, ``_import_*``) +``tts_tool_openai``. Origin seams (``_resolve_provider_key``, ``_import_*``) are resolved through :func:`_origin` at call time. """ @@ -314,7 +314,8 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - if creds.get("provider") == "xai-oauth": base_url = creds.get("base_url") else: - base_url = xai_config.get("base_url") or creds.get("base_url") or _origin().get_env_value("XAI_BASE_URL") + from hermes_cli.config import get_env_value + base_url = xai_config.get("base_url") or creds.get("base_url") or get_env_value("XAI_BASE_URL") base_url = str(base_url or DEFAULT_XAI_BASE_URL).strip().rstrip("/") # Documented minimal POST /v1/tts shape; optional fields only when they differ from defaults. @@ -399,8 +400,9 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any base_url = runtime.endpoint # MiniMax scopes TTS requests by GroupId (``?GroupId=`` on the t2a_v2 URL): config or # MINIMAX_GROUP_ID, attached only when absent from the URL. + from hermes_cli.config import get_env_value group_id = (str(mm_config.get("group_id") or "").strip() - or (_origin().get_env_value("MINIMAX_GROUP_ID") or "").strip()) + or (get_env_value("MINIMAX_GROUP_ID") or "").strip()) if group_id and "GroupId=" not in base_url: base_url = f"{base_url}{'&' if '?' in base_url else '?'}GroupId={group_id}" is_t2a_v2 = "t2a_v2" in base_url @@ -565,7 +567,8 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] gemini_config = _section(tts_config, "gemini") model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL 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 origin.get_env_value("GEMINI_BASE_URL") + from hermes_cli.config import get_env_value + base_url = str(gemini_config.get("base_url") or get_env_value("GEMINI_BASE_URL") or DEFAULT_GEMINI_TTS_BASE_URL).strip().rstrip("/") persona_prompt = _read_gemini_persona_prompt(gemini_config) tts_script = text diff --git a/tools/voice_client_config.py b/tools/voice_client_config.py index 512074c574..87b201dac8 100644 --- a/tools/voice_client_config.py +++ b/tools/voice_client_config.py @@ -100,7 +100,8 @@ def _resolve_stt_client_config() -> Dict[str, Any]: return _direct(wire, provider, base_url, api_key, model, language=language) def env_base_url(env_var: str, default: str) -> str: - return str(section.get("base_url") or tt.get_env_value(env_var) or default).strip().rstrip("/") + from hermes_cli.config import get_env_value + return str(section.get("base_url") or get_env_value(env_var) or default).strip().rstrip("/") if provider in _STT_KEYED: env_var, default_model, base = _STT_KEYED[provider] @@ -120,7 +121,8 @@ def _resolve_stt_client_config() -> Dict[str, Any]: if provider == "xai": # API key only: an xAI OAuth bearer refreshes server-side mid-session and # would strand the client on the first 401. - api_key = str(tt.get_env_value("XAI_API_KEY") or "").strip() + from hermes_cli.config import get_env_value + api_key = str(get_env_value("XAI_API_KEY") or "").strip() if not api_key: return _relay("xai oauth (server-managed) or no credentials") return direct(STT_WIRE_XAI, env_base_url("XAI_STT_BASE_URL", tc.XAI_STT_BASE_URL), api_key, None) diff --git a/tools/xai_http.py b/tools/xai_http.py index 3041521625..a32dba55f9 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -49,19 +49,6 @@ def has_xai_credentials() -> bool: return False -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``. - """ - try: - from hermes_cli.config import get_env_value as _hermes_get_env_value - except ImportError: - return os.environ.get(name, default) - value = _hermes_get_env_value(name) - return value if value is not None else default - - def hermes_xai_user_agent() -> str: """Return a stable Hermes-specific User-Agent for xAI HTTP calls.""" try: @@ -178,11 +165,12 @@ def _resolve_explicit_xai_api_key() -> str: (incl. failing closed in a multiplexed gateway turn) is never re-implemented per caller. """ from tools.tool_backend_helpers import resolve_provider_secret - return resolve_provider_secret("XAI_API_KEY", "xai", env_getter=get_env_value) + return resolve_provider_secret("XAI_API_KEY", "xai") def _xai_base_url_override() -> str: """``HERMES_XAI_BASE_URL`` then ``XAI_BASE_URL``, stripped; '' when unset.""" + from hermes_cli.config import get_env_value return str(get_env_value("HERMES_XAI_BASE_URL") or get_env_value("XAI_BASE_URL") or "").strip().rstrip("/") @@ -235,6 +223,7 @@ def resolve_xai_http_credentials( except Exception: pass + from hermes_cli.config import get_env_value api_key = _resolve_explicit_xai_api_key() base_url = str(get_env_value("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL).strip().rstrip("/") return {"provider": "xai", "api_key": api_key, "base_url": base_url}