feat(tavily): update Tavily integration to support keyless access
- Updated the Tavily API key description to clarify that it is optional and keyless access is supported. - Modified the Tavily plugin and provider to handle requests with or without an API key, using Bearer authentication when the key is provided. - Enhanced documentation to reflect the new keyless functionality and updated environment variable descriptions. - Added tests to ensure correct behavior for both keyed and keyless requests.
This commit is contained in:
BIN
default.tar.gz
Normal file
BIN
default.tar.gz
Normal file
Binary file not shown.
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/)) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user