fix(web): widen config-aware env resolution to exa/parallel/tavily/brave-free providers
Same bug class as #40190: these providers read credentials via bare os.getenv(), so keys stored in ~/.hermes/.env (hermes config layer) were invisible in execution paths that never exported them into the process environment. Add get_provider_env() on the WebSearchProvider module as the shared config-aware lookup (get_env_value with os.getenv fallback) and route all credential reads through it. SearXNG already did this (#34290); Firecrawl fixed in the preceding cherry-picked commit by @liuhao1024.
This commit is contained in:
parent
026ab4737d
commit
1a2885535b
6 changed files with 106 additions and 11 deletions
|
|
@ -52,7 +52,33 @@ On failure (either capability)::
|
|||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any, Dict, List
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def get_provider_env(name: str) -> str:
|
||||
"""Config-aware env lookup for web providers.
|
||||
|
||||
Resolves *name* via :func:`hermes_cli.config.get_env_value` (checks
|
||||
``os.environ`` first, then ``~/.hermes/.env``) so credentials set
|
||||
through Hermes' config layer are visible even when they were never
|
||||
exported into the process environment — gateway sessions, delegate
|
||||
children, and subprocess agent runs (issue #40190). Falls back to a
|
||||
bare ``os.getenv`` when the config module is unavailable (stripped
|
||||
installs, early import contexts).
|
||||
|
||||
Returns the stripped value, or ``""`` when unset.
|
||||
"""
|
||||
val: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
val = get_env_value(name)
|
||||
except Exception: # noqa: BLE001 — config layer optional here
|
||||
val = None
|
||||
if val is None:
|
||||
val = os.getenv(name, "")
|
||||
return (val or "").strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ class BraveFreeWebSearchProvider(WebSearchProvider):
|
|||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value."""
|
||||
return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip())
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
return bool(get_provider_env("BRAVE_SEARCH_API_KEY"))
|
||||
|
||||
def supports_search(self) -> bool:
|
||||
return True
|
||||
|
|
@ -65,7 +67,9 @@ class BraveFreeWebSearchProvider(WebSearchProvider):
|
|||
"""
|
||||
import httpx
|
||||
|
||||
api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip()
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
api_key = get_provider_env("BRAVE_SEARCH_API_KEY")
|
||||
if not api_key:
|
||||
return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ def _get_exa_client() -> Any:
|
|||
if cached is not None:
|
||||
return cached
|
||||
|
||||
api_key = os.getenv("EXA_API_KEY")
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
api_key = get_provider_env("EXA_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"EXA_API_KEY environment variable not set. "
|
||||
|
|
@ -100,7 +102,9 @@ class ExaWebSearchProvider(WebSearchProvider):
|
|||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when ``EXA_API_KEY`` is set to a non-empty value."""
|
||||
return bool(os.getenv("EXA_API_KEY", "").strip())
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
return bool(get_provider_env("EXA_API_KEY"))
|
||||
|
||||
def supports_search(self) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ def _get_sync_client() -> Any:
|
|||
if cached is not None:
|
||||
return cached
|
||||
|
||||
api_key = os.getenv("PARALLEL_API_KEY")
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
api_key = get_provider_env("PARALLEL_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"PARALLEL_API_KEY environment variable not set. "
|
||||
|
|
@ -99,7 +101,9 @@ def _get_async_client() -> Any:
|
|||
if cached is not None:
|
||||
return cached
|
||||
|
||||
api_key = os.getenv("PARALLEL_API_KEY")
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
api_key = get_provider_env("PARALLEL_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"PARALLEL_API_KEY environment variable not set. "
|
||||
|
|
@ -153,7 +157,9 @@ class ParallelWebSearchProvider(WebSearchProvider):
|
|||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when ``PARALLEL_API_KEY`` is set to a non-empty value."""
|
||||
return bool(os.getenv("PARALLEL_API_KEY", "").strip())
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
return bool(get_provider_env("PARALLEL_API_KEY"))
|
||||
|
||||
def supports_search(self) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -41,14 +41,16 @@ def _tavily_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"""
|
||||
import httpx
|
||||
|
||||
api_key = os.getenv("TAVILY_API_KEY")
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
api_key = get_provider_env("TAVILY_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"TAVILY_API_KEY environment variable not set. "
|
||||
"Get your API key at https://app.tavily.com/home"
|
||||
)
|
||||
|
||||
base_url = os.getenv("TAVILY_BASE_URL", "https://api.tavily.com")
|
||||
base_url = get_provider_env("TAVILY_BASE_URL") or "https://api.tavily.com"
|
||||
payload = dict(payload) # don't mutate caller's dict
|
||||
payload["api_key"] = api_key
|
||||
url = f"{base_url}/{endpoint.lstrip('/')}"
|
||||
|
|
@ -138,7 +140,9 @@ class TavilyWebSearchProvider(WebSearchProvider):
|
|||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when ``TAVILY_API_KEY`` is set to a non-empty value."""
|
||||
return bool(os.getenv("TAVILY_API_KEY", "").strip())
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
return bool(get_provider_env("TAVILY_API_KEY"))
|
||||
|
||||
def supports_search(self) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -828,3 +828,54 @@ class TestFirecrawlEnvResolution:
|
|||
assert result is not None
|
||||
kwargs, _cache_key = result
|
||||
assert kwargs["api_url"] == fake_url.rstrip("/")
|
||||
|
||||
|
||||
class TestSiblingProvidersEnvResolution:
|
||||
"""The same #40190 bug class widened: every keyed web provider must
|
||||
resolve its credential through the config-aware lookup (os.environ OR
|
||||
~/.hermes/.env), not bare os.getenv. Parametrized over the four
|
||||
providers that previously read only the process environment."""
|
||||
|
||||
_CASES = [
|
||||
("plugins.web.exa.provider", "ExaWebSearchProvider", "EXA_API_KEY"),
|
||||
("plugins.web.parallel.provider", "ParallelWebSearchProvider", "PARALLEL_API_KEY"),
|
||||
("plugins.web.tavily.provider", "TavilyWebSearchProvider", "TAVILY_API_KEY"),
|
||||
("plugins.web.brave_free.provider", "BraveFreeWebSearchProvider", "BRAVE_SEARCH_API_KEY"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("module_path,cls_name,env_key", _CASES)
|
||||
def test_is_available_reads_via_get_env_value(
|
||||
self, monkeypatch, module_path, cls_name, env_key
|
||||
):
|
||||
"""is_available() must see a key that lives only in the .env layer."""
|
||||
monkeypatch.delenv(env_key, raising=False)
|
||||
|
||||
import importlib
|
||||
module = importlib.import_module(module_path)
|
||||
provider = getattr(module, cls_name)()
|
||||
|
||||
assert provider.is_available() is False
|
||||
|
||||
with patch(
|
||||
"hermes_cli.config.get_env_value",
|
||||
side_effect=lambda k: "test-key-from-dotenv" if k == env_key else None,
|
||||
):
|
||||
assert provider.is_available() is True, (
|
||||
f"{cls_name}.is_available() ignored {env_key} from the "
|
||||
"config-aware env layer (get_env_value)"
|
||||
)
|
||||
|
||||
def test_get_provider_env_falls_back_to_os_environ(self, monkeypatch):
|
||||
"""When the config layer has no value, process env still wins."""
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
monkeypatch.setenv("WSP_TEST_FALLBACK_KEY", " from-process-env ")
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None):
|
||||
assert get_provider_env("WSP_TEST_FALLBACK_KEY") == "from-process-env"
|
||||
|
||||
def test_get_provider_env_unset_returns_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("WSP_TEST_UNSET_KEY", raising=False)
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None):
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
assert get_provider_env("WSP_TEST_UNSET_KEY") == ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue