diff --git a/default.tar.gz b/default.tar.gz new file mode 100644 index 0000000000..f1a45248c4 Binary files /dev/null and b/default.tar.gz differ diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index cccb4b638f..448dd0c071 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -4113,7 +4113,7 @@ OPTIONAL_ENV_VARS = { "advanced": True, }, "TAVILY_API_KEY": { - "description": "Tavily API key for AI-native web search and extract", + "description": "Tavily API key for AI-native web search and extract (optional — keyless works without it)", "prompt": "Tavily API key", "url": "https://app.tavily.com/home", "tools": ["web_search", "web_extract"], diff --git a/plugins/web/tavily/plugin.yaml b/plugins/web/tavily/plugin.yaml index 7eb1e9fc45..ae1676f211 100644 --- a/plugins/web/tavily/plugin.yaml +++ b/plugins/web/tavily/plugin.yaml @@ -1,6 +1,6 @@ name: web-tavily version: 1.0.0 -description: "Tavily web search + content extraction + crawl. Search + extract are mainstream; crawl is unique to Tavily among built-in providers. Requires TAVILY_API_KEY — sign up at https://app.tavily.com/home." +description: "Tavily web search + content extraction. Works keyless (rate-limited); set TAVILY_API_KEY for higher limits — https://app.tavily.com/home." author: NousResearch kind: backend provides_web_providers: diff --git a/plugins/web/tavily/provider.py b/plugins/web/tavily/provider.py index e2a9d7b40f..06c31281f1 100644 --- a/plugins/web/tavily/provider.py +++ b/plugins/web/tavily/provider.py @@ -17,47 +17,62 @@ Config keys this provider responds to:: Env vars:: - TAVILY_API_KEY=... # https://app.tavily.com/home (required) + TAVILY_API_KEY=... # https://app.tavily.com/home (optional) TAVILY_BASE_URL=... # optional override of https://api.tavily.com + +Auth is header-based. A key uses ``Authorization: Bearer``; without a key +the request is keyless (``X-Tavily-Access-Mode: keyless``). Both paths +send ``X-Client-Name: hermes-agent``. """ from __future__ import annotations import logging -import os from typing import Any, Dict, List +import httpx + from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) +_CLIENT_NAME = "hermes-agent" + + +def _tavily_headers(api_key: str) -> Dict[str, str]: + """Build Tavily request headers for keyed or keyless access.""" + headers = {"X-Client-Name": _CLIENT_NAME} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + else: + headers["X-Tavily-Access-Mode"] = "keyless" + return headers + def _tavily_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: """POST to the Tavily API and return the parsed JSON response. - Mirrors :func:`tools.web_tools._tavily_request`. Raises ``ValueError`` - when ``TAVILY_API_KEY`` is unset; the caller catches and surfaces as - a typed error response. + Keyed when ``TAVILY_API_KEY`` is set (Bearer auth); otherwise keyless. + Non-2xx responses raise ``ValueError`` with the response body so Tavily's + keyless rate-limit / upgrade text reaches the model. """ - import httpx - 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 = 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('/')}" logger.info("Tavily %s request to %s", endpoint, url) - response = httpx.post(url, json=payload, timeout=60) - response.raise_for_status() + response = httpx.post( + url, + json=payload, + timeout=60, + headers=_tavily_headers(api_key), + ) + if response.status_code >= 400: + body = (response.text or "").strip() + detail = body or f"HTTP {response.status_code}" + raise ValueError(detail) return response.json() @@ -212,12 +227,12 @@ class TavilyWebSearchProvider(WebSearchProvider): def get_setup_schema(self) -> Dict[str, Any]: return { "name": "Tavily", - "badge": "paid", - "tag": "Search + extract in one provider.", + "badge": "free | key optional", + "tag": "Search + extract. Works keyless; set TAVILY_API_KEY for higher limits.", "env_vars": [ { "key": "TAVILY_API_KEY", - "prompt": "Tavily API key", + "prompt": "Tavily API key (optional — keyless works without it)", "url": "https://app.tavily.com/home", }, ], diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index 731fc9af0b..f02bc9b311 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -202,19 +202,38 @@ class TestUnconfiguredErrorEnvelopeParity: from agent import web_search_registry self._clear_web_creds(monkeypatch) - # Reset firecrawl client cache so the unconfigured state is re-evaluated monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False) monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False) monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False) monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) monkeypatch.setattr(web_search_registry, "_keyless_tier_enabled", lambda: False) + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + + result = json.loads(web_tools.web_search_tool("hello world", limit=3)) + assert "error" in result, f"expected top-level 'error' key, got {result}" + assert "Error searching web:" in result["error"] + assert "FIRECRAWL_API_KEY" in result["error"] + assert "results" not in result + + + def test_explicit_firecrawl_unconfigured_emits_top_level_error(self, monkeypatch): + """``web.backend: firecrawl`` with no creds still uses the Firecrawl + error envelope — keyless Tavily must not silently take over. + """ + from tools import web_tools + + self._clear_web_creds(monkeypatch) + monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False) + monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False) + monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False) + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "firecrawl"}) + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False) result = json.loads(web_tools.web_search_tool("hello world", limit=3)) assert "error" in result, f"expected top-level 'error' key, got {result}" - # ``Error searching web:`` prefix comes from web_tools' top-level except handler assert "Error searching web:" in result["error"] assert "FIRECRAWL_API_KEY" in result["error"] - # No per-result burying assert "results" not in result @@ -317,6 +336,10 @@ class TestDispatchersTriggerPluginDiscovery: web_tools, "_load_web_config", lambda: {"extract_backend": "firecrawl"}, ) + monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-test") + async def _allow_ssrf(_url: str) -> bool: + return True + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) # Sanity: registry IS empty before the tool call. assert web_search_registry.get_provider("firecrawl") is None diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 78c4573025..a4753c74c0 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -224,7 +224,9 @@ class TestBackendSelection: """ from tools.web_tools import _get_backend with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ patch("tools.web_tools._ddgs_package_importable", return_value=False), \ + patch("tools.web_tools._list_registered_web_providers", return_value=[]), \ patch("agent.web_search_registry._keyless_tier_enabled", return_value=False): assert _get_backend() == "firecrawl" @@ -662,6 +664,28 @@ class TestSiblingProvidersEnvResolution: "config-aware env layer (get_env_value)" ) + def test_tavily_request_reads_key_via_get_env_value(self, monkeypatch): + """Keyed Tavily must Bearer-auth with a key that lives only in .env.""" + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_response.text = "{}" + + with patch( + "hermes_cli.config.get_env_value", + side_effect=lambda k: "tvly-from-dotenv" if k == "TAVILY_API_KEY" else None, + ), patch( + "plugins.web.tavily.provider.httpx.post", return_value=mock_response + ) as mock_post: + from plugins.web.tavily.provider import _tavily_request + + _tavily_request("search", {"query": "q"}) + headers = mock_post.call_args.kwargs["headers"] + assert headers["Authorization"] == "Bearer tvly-from-dotenv" + assert headers["X-Client-Name"] == "hermes-agent" + assert "X-Tavily-Access-Mode" not in headers + def test_get_provider_env_unset_returns_empty(self, monkeypatch): monkeypatch.delenv("WSP_TEST_UNSET_KEY", raising=False) diff --git a/tests/tools/test_web_tools_tavily.py b/tests/tools/test_web_tools_tavily.py index f7345ad0fc..92baef2aa8 100644 --- a/tests/tools/test_web_tools_tavily.py +++ b/tests/tools/test_web_tools_tavily.py @@ -1,10 +1,11 @@ """Tests for Tavily web backend integration. Coverage: - _tavily_request() — API key handling, endpoint construction, error propagation. + _tavily_request() — keyed Bearer vs keyless header, attribution, error bodies. _normalize_tavily_search_results() — search response normalization. _normalize_tavily_documents() — extract response normalization, failed_results. web_search_tool / web_extract_tool — Tavily dispatch paths. + auto-detect ranking — keyed paid-band; keyless only when Tavily is selected. """ import json @@ -16,49 +17,72 @@ from unittest.mock import patch, MagicMock from tests.tools.conftest import register_all_web_providers +def _ok_response(payload=None): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = payload if payload is not None else {"results": []} + mock_response.text = json.dumps(mock_response.json.return_value) + return mock_response + + # ─── _tavily_request ───────────────────────────────────────────────────────── class TestTavilyRequest: """Test suite for the _tavily_request helper.""" - def test_raises_without_api_key(self): - """No TAVILY_API_KEY → ValueError with guidance.""" + def test_keyless_when_no_api_key(self): + """No TAVILY_API_KEY → keyless header, no Authorization, no body key.""" + mock_response = _ok_response() + with patch.dict(os.environ, {}, clear=False): os.environ.pop("TAVILY_API_KEY", None) - from tools.web_tools import _tavily_request - with pytest.raises(ValueError, match="TAVILY_API_KEY"): + with patch("plugins.web.tavily.provider.httpx.post", return_value=mock_response) as mock_post: + from plugins.web.tavily.provider import _tavily_request _tavily_request("search", {"query": "test"}) - def test_posts_with_api_key_in_body(self): - """api_key is injected into the JSON payload.""" - mock_response = MagicMock() - mock_response.json.return_value = {"results": []} - mock_response.raise_for_status = MagicMock() + mock_post.assert_called_once() + headers = mock_post.call_args.kwargs["headers"] + payload = mock_post.call_args.kwargs["json"] + assert headers["X-Client-Name"] == "hermes-agent" + assert headers["X-Tavily-Access-Mode"] == "keyless" + assert "Authorization" not in headers + assert "api_key" not in payload + assert payload["query"] == "test" + assert "api.tavily.com/search" in mock_post.call_args.args[0] + + def test_keyed_uses_bearer_not_body(self): + """TAVILY_API_KEY → Bearer auth, attribution, no body api_key.""" + mock_response = _ok_response() with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test-key"}): - with patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post: - from tools.web_tools import _tavily_request - result = _tavily_request("search", {"query": "hello"}) + with patch("plugins.web.tavily.provider.httpx.post", return_value=mock_response) as mock_post: + from plugins.web.tavily.provider import _tavily_request + _tavily_request("search", {"query": "hello"}) mock_post.assert_called_once() - call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - assert payload["api_key"] == "tvly-test-key" + headers = mock_post.call_args.kwargs["headers"] + payload = mock_post.call_args.kwargs["json"] + assert headers == { + "X-Client-Name": "hermes-agent", + "Authorization": "Bearer tvly-test-key", + } + assert "X-Tavily-Access-Mode" not in headers + assert "api_key" not in payload assert payload["query"] == "hello" - assert "api.tavily.com/search" in call_kwargs.args[0] + assert "api.tavily.com/search" in mock_post.call_args.args[0] - def test_raises_on_http_error(self): - """Non-2xx responses propagate as httpx.HTTPStatusError.""" - import httpx as _httpx + def test_http_error_surfaces_response_body(self): + """Non-2xx responses raise ValueError with Tavily's response body.""" mock_response = MagicMock() - mock_response.raise_for_status.side_effect = _httpx.HTTPStatusError( - "401 Unauthorized", request=MagicMock(), response=mock_response - ) + mock_response.status_code = 429 + mock_response.text = "Rate limit hit. Sign up for a free API key at https://app.tavily.com" + mock_response.json.return_value = {} - with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-bad-key"}): - with patch("tools.web_tools.httpx.post", return_value=mock_response): - from tools.web_tools import _tavily_request - with pytest.raises(_httpx.HTTPStatusError): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAVILY_API_KEY", None) + with patch("plugins.web.tavily.provider.httpx.post", return_value=mock_response): + from plugins.web.tavily.provider import _tavily_request + with pytest.raises(ValueError, match="Rate limit hit"): _tavily_request("search", {"query": "test"}) @@ -98,7 +122,7 @@ class TestNormalizeTavilySearchResults: # ─── _normalize_tavily_documents ────────────────────────────────────────────── class TestNormalizeTavilyDocuments: - """Test extract/crawl document normalization.""" + """Test extract document normalization.""" def test_basic_document(self): from tools.web_tools import _normalize_tavily_documents @@ -125,6 +149,78 @@ class TestNormalizeTavilyDocuments: assert docs[0]["url"] == "https://fallback.com" +# ─── availability / auto-detect ─────────────────────────────────────────────── + +class TestTavilyAvailability: + """Keyed Tavily stays in the paid band; keyless only when selected.""" + + def test_is_available_without_key(self): + from plugins.web.tavily.provider import TavilyWebSearchProvider + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAVILY_API_KEY", None) + assert TavilyWebSearchProvider().is_available() is False + + def test_is_backend_available_without_key(self): + from tools.web_tools import _is_backend_available + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAVILY_API_KEY", None) + assert _is_backend_available("tavily") is False + + def test_is_backend_available_when_configured_without_key(self): + from tools.web_tools import _is_backend_available + with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAVILY_API_KEY", None) + assert _is_backend_available("tavily") is True + + def test_keyless_does_not_preempt_managed_firecrawl(self): + """No TAVILY_API_KEY + Nous gateway ready → firecrawl, not keyless tavily.""" + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=True), \ + patch("tools.web_tools._ddgs_package_importable", return_value=False): + os.environ.pop("TAVILY_API_KEY", None) + assert _get_backend() == "firecrawl" + + def test_keyless_does_not_preempt_ddgs(self): + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ + patch("tools.web_tools._ddgs_package_importable", return_value=True): + os.environ.pop("TAVILY_API_KEY", None) + assert _get_backend() == "ddgs" + + def test_no_keys_defaults_to_firecrawl(self): + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ + patch("tools.web_tools._ddgs_package_importable", return_value=False), \ + patch("tools.web_tools._list_registered_web_providers", return_value=[]): + os.environ.pop("TAVILY_API_KEY", None) + assert _get_backend() == "firecrawl" + + def test_explicit_search_backend_tavily_without_key(self): + """web.search_backend=tavily sticks even with no TAVILY_API_KEY.""" + from tools.web_tools import _get_search_backend + with patch("tools.web_tools._load_web_config", + return_value={"backend": "firecrawl", "search_backend": "tavily"}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=True): + os.environ.pop("TAVILY_API_KEY", None) + assert _get_search_backend() == "tavily" + + def test_check_web_api_key_when_tavily_configured_without_key(self): + from tools.web_tools import check_web_api_key + with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ + patch("tools.web_tools.check_firecrawl_api_key", return_value=False), \ + patch("tools.web_tools._ddgs_package_importable", return_value=False), \ + patch("agent.web_search_registry.get_active_search_provider", return_value=None), \ + patch("agent.web_search_registry.get_active_extract_provider", return_value=None): + os.environ.pop("TAVILY_API_KEY", None) + assert check_web_api_key() is True + + # ─── web_search_tool (Tavily dispatch) ──────────────────────────────────────── class TestWebSearchTavily: @@ -140,15 +236,13 @@ class TestWebSearchTavily: _reset_for_tests() def test_search_dispatches_to_tavily(self): - mock_response = MagicMock() - mock_response.json.return_value = { + mock_response = _ok_response({ "results": [{"title": "Result", "url": "https://r.com", "content": "desc", "score": 0.9}] - } - mock_response.raise_for_status = MagicMock() + }) with patch("tools.web_tools._get_backend", return_value="tavily"), \ patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \ - patch("tools.web_tools.httpx.post", return_value=mock_response), \ + patch("plugins.web.tavily.provider.httpx.post", return_value=mock_response), \ patch("tools.interrupt.is_interrupted", return_value=False): from tools.web_tools import web_search_tool result = json.loads(web_search_tool("test query", limit=3)) @@ -156,6 +250,22 @@ class TestWebSearchTavily: assert len(result["data"]["web"]) == 1 assert result["data"]["web"][0]["title"] == "Result" + def test_search_keyless_dispatch(self): + mock_response = _ok_response({ + "results": [{"title": "Result", "url": "https://r.com", "content": "desc"}] + }) + + with patch("tools.web_tools._get_backend", return_value="tavily"), \ + patch("plugins.web.tavily.provider.httpx.post", return_value=mock_response) as mock_post, \ + patch("tools.interrupt.is_interrupted", return_value=False): + os.environ.pop("TAVILY_API_KEY", None) + from tools.web_tools import web_search_tool + result = json.loads(web_search_tool("test query")) + assert result["success"] is True + headers = mock_post.call_args.kwargs["headers"] + assert headers["X-Tavily-Access-Mode"] == "keyless" + assert headers["X-Client-Name"] == "hermes-agent" + # ─── web_extract_tool (Tavily dispatch) ─────────────────────────────────────── @@ -172,15 +282,17 @@ class TestWebExtractTavily: _reset_for_tests() def test_extract_dispatches_to_tavily(self): - mock_response = MagicMock() - mock_response.json.return_value = { + mock_response = _ok_response({ "results": [{"url": "https://example.com", "raw_content": "Extracted content", "title": "Page"}] - } - mock_response.raise_for_status = MagicMock() + }) + + async def _allow_ssrf(_url: str) -> bool: + return True with patch("tools.web_tools._get_backend", return_value="tavily"), \ patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \ - patch("tools.web_tools.httpx.post", return_value=mock_response): + patch("plugins.web.tavily.provider.httpx.post", return_value=mock_response), \ + patch("tools.web_tools.async_is_safe_url", _allow_ssrf): from tools.web_tools import web_extract_tool result = json.loads(asyncio.get_event_loop().run_until_complete( web_extract_tool(["https://example.com"]) @@ -189,4 +301,3 @@ class TestWebExtractTavily: assert len(result["results"]) == 1 assert result["results"][0]["url"] == "https://example.com" assert "Extracted content" in result["results"][0]["content"] - diff --git a/tools/web_tools.py b/tools/web_tools.py index 510048ed90..ac9efcab73 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -358,6 +358,14 @@ def _get_capability_backend(capability: str) -> str: return _get_backend() +def _tavily_explicitly_configured() -> bool: + cfg = _load_web_config() + return any( + (cfg.get(key) or "").lower().strip() == "tavily" + for key in ("backend", "search_backend", "extract_backend") + ) + + def _is_backend_available(backend: str) -> bool: """Return True when the selected backend is currently usable. @@ -382,7 +390,7 @@ def _is_backend_available(backend: str) -> bool: if backend == "firecrawl": return check_firecrawl_api_key() if backend == "tavily": - return _has_env("TAVILY_API_KEY") + return _has_env("TAVILY_API_KEY") or _tavily_explicitly_configured() if backend == "searxng": return _has_env("SEARXNG_URL") if backend == "brave-free": @@ -1224,7 +1232,10 @@ if __name__ == "__main__": elif backend == "parallel": print(" Using Parallel API (https://parallel.ai)") elif backend == "tavily": - print(" Using Tavily API (https://tavily.com)") + if _has_env("TAVILY_API_KEY"): + print(" Using Tavily API (https://tavily.com)") + else: + print(" Using Tavily keyless (https://docs.tavily.com/documentation/keyless)") elif backend == "searxng": print(f" Using SearXNG (search only): {_env_value('SEARXNG_URL')}") elif backend == "brave-free": diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md index 9780de27a4..eef508952a 100644 --- a/website/docs/integrations/index.md +++ b/website/docs/integrations/index.md @@ -34,7 +34,7 @@ The `web_search` and `web_extract` tools support eight backend providers, config | **SearXNG** | `SEARXNG_URL` | ✔ | — | — | | **Brave** (free tier) | `BRAVE_SEARCH_API_KEY` | ✔ | — | — | | **DuckDuckGo** (ddgs) | _(none)_ | ✔ | — | — | -| **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ | ✔ | +| **Tavily** | `TAVILY_API_KEY` (optional) | ✔ | ✔ | — | | **Exa** | `EXA_API_KEY` | ✔ | ✔ | — | | **Parallel** | `PARALLEL_API_KEY` | ✔ | ✔ | — | | **xAI** | `XAI_API_KEY` | ✔ | — | — | @@ -46,7 +46,7 @@ web: backend: firecrawl # firecrawl | searxng | brave-free | ddgs | tavily | exa | parallel | xai ``` -If `web.backend` is not set, the backend is auto-detected from whichever API key is available. Self-hosted Firecrawl is also supported via `FIRECRAWL_API_URL`. +If `web.backend` is not set, the backend is auto-detected from whichever API key is available. Self-hosted Firecrawl is also supported via `FIRECRAWL_API_URL`. Selecting Tavily in `hermes tools` works without a key. ## Browser Automation diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 87efeef579..e35fbcaf0a 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -140,7 +140,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `PARALLEL_API_KEY` | AI-native web search ([parallel.ai](https://parallel.ai/)) | | `FIRECRAWL_API_KEY` | Web scraping and cloud browser ([firecrawl.dev](https://firecrawl.dev/)) | | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | -| `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) | +| `TAVILY_API_KEY` | Optional Tavily API key for higher search/extract limits. After selecting Tavily as the web backend, keyless access works without it ([app.tavily.com](https://app.tavily.com/home), [keyless docs](https://docs.tavily.com/documentation/keyless)) | | `SEARXNG_URL` | SearXNG instance URL for free self-hosted web search — no API key required ([searxng.github.io](https://searxng.github.io/searxng/)) | | `TAVILY_BASE_URL` | Override the Tavily API endpoint. Useful for corporate proxies and self-hosted Tavily-compatible search backends. Same pattern as `GROQ_BASE_URL`. | | `EXA_API_KEY` | Exa API key for AI-native web search and contents ([exa.ai](https://exa.ai/)) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 28d58bd911..4787fc8093 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2287,10 +2287,10 @@ web: | **Firecrawl** (default) | `FIRECRAWL_API_KEY` | ✔ | ✔ | | **SearXNG** | `SEARXNG_URL` | ✔ | — | | **Parallel** | `PARALLEL_API_KEY` (optional — keyless free tier) | ✔ | ✔ | -| **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ | +| **Tavily** | `TAVILY_API_KEY` (optional — keyless when selected) | ✔ | ✔ | | **Exa** | `EXA_API_KEY` (optional — keyless free tier) | ✔ | ✔ | -**Backend selection:** The runtime always uses the stored `web.backend` selection (set via `hermes tools`; `nous` routes through the managed Tool Gateway). Only if no web backend has ever been selected is one auto-detected from available API keys: if only `SEARXNG_URL` is set, SearXNG is used; if only `EXA_API_KEY` is set, Exa; if only `TAVILY_API_KEY` is set, Tavily; if only `PARALLEL_API_KEY` is set, Parallel. With **no selection and no credentials at all**, Hermes falls back to the Exa/Parallel keyless free tier (unpinned installs split 50/50 between the vendors) so web tools work on a fresh install — see the [Web Search guide](/user-guide/features/web-search) for details and limits. Once a selection exists, adding a key to `.env` does not change the route. +**Backend selection:** The runtime always uses the stored `web.backend` selection (set via `hermes tools`; `nous` routes through the managed Tool Gateway). Only if no web backend has ever been selected is one auto-detected from available API keys: if only `SEARXNG_URL` is set, SearXNG is used; if only `EXA_API_KEY` is set, Exa; if only `TAVILY_API_KEY` is set, Tavily; if only `PARALLEL_API_KEY` is set, Parallel. With **no selection and no credentials at all**, Hermes falls back to the Exa/Parallel keyless free tier (unpinned installs split 50/50 between the vendors) so web tools work on a fresh install — see the [Web Search guide](/user-guide/features/web-search) for details and limits. Once a selection exists, adding a key to `.env` does not change the route. Selecting Tavily in `hermes tools` (or `web.backend: tavily`) also works without a key. **SearXNG** is a free, self-hosted, privacy-respecting metasearch engine that queries 70+ search engines. No API key needed — just set `SEARXNG_URL` to your instance (e.g., `http://localhost:8080`). SearXNG is search-only; `web_extract` requires a separate extract provider (set `web.extract_backend`). See the [Web Search setup guide](/user-guide/features/web-search) for Docker setup instructions. diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index 57933148a2..cd89aab090 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -22,7 +22,7 @@ Both are configured through a single backend selection. Providers are chosen via | **SearXNG** | `SEARXNG_URL` | ✔ | — | ✔ Free (self-hosted) | | **Brave Search (free tier)** | `BRAVE_SEARCH_API_KEY` | ✔ | — | 2 000 queries/mo | | **DDGS (DuckDuckGo)** | — (no key) | ✔ | — | ✔ Free | -| **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ | 1 000 searches/mo | +| **Tavily** | `TAVILY_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless when selected · 1 000 searches/mo with a free key | | **Exa** | `EXA_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier · 1 000 searches/mo with key | | **Parallel** | `PARALLEL_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier · paid with key | | **xAI (Grok)** | `XAI_API_KEY` or `hermes auth add xai-oauth` | ✔ | — | Paid (SuperGrok or per-token) | @@ -239,14 +239,17 @@ With this config, Hermes uses SearXNG for all search queries and Firecrawl for U ### Tavily -AI-optimised search and extract with a generous free tier. +AI-optimised search and extract. Select Tavily in `hermes tools` (or set `web.backend: tavily`) to use it **keyless** with no account (rate-limited). Set an API key when you want higher limits. ```bash +# optional — skip this for keyless access after selecting Tavily # ~/.hermes/.env TAVILY_API_KEY=tvly-your-key-here ``` -Get a key at [app.tavily.com](https://app.tavily.com/home). The free tier includes 1 000 searches/month. +Get a key at [app.tavily.com](https://app.tavily.com/home). See [Tavily keyless](https://docs.tavily.com/documentation/keyless). + +Empty installs keep Firecrawl as the named default. Keyless Tavily is not auto-selected. ---