Skip to content

Commit bd8c482

Browse files
authored
feat(web): add Tavily as web search/extract/crawl backend (NousResearch#1731)
Salvage of PR NousResearch#1707 by @kshitijk4poor (cherry-picked with authorship preserved). Adds Tavily as a third web backend alongside Firecrawl and Parallel, using the Tavily REST API via httpx. - Backend selection via hermes tools → saved as web.backend in config.yaml - All three tools supported: search, extract, crawl - TAVILY_API_KEY in config registry, doctor, status, setup wizard - 15 new Tavily tests + 9 backend selection tests + 5 config tests - Backward compatible Closes NousResearch#1707
1 parent 63c2856 commit bd8c482

9 files changed

Lines changed: 582 additions & 12 deletions

File tree

hermes_cli/config.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ def ensure_hermes_home():
379379
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
380380
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
381381
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
382+
10: ["TAVILY_API_KEY"],
382383
}
383384

384385
# Required environment variables with metadata for migration prompts.
@@ -574,6 +575,14 @@ def ensure_hermes_home():
574575
"category": "tool",
575576
"advanced": True,
576577
},
578+
"TAVILY_API_KEY": {
579+
"description": "Tavily API key for AI-native web search, extract, and crawl",
580+
"prompt": "Tavily API key",
581+
"url": "https://app.tavily.com/home",
582+
"tools": ["web_search", "web_extract", "web_crawl"],
583+
"password": True,
584+
"category": "tool",
585+
},
577586
"BROWSERBASE_API_KEY": {
578587
"description": "Browserbase API key for cloud browser (optional — local browser works without this)",
579588
"prompt": "Browserbase API key",
@@ -1516,6 +1525,7 @@ def show_config():
15161525
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
15171526
("PARALLEL_API_KEY", "Parallel"),
15181527
("FIRECRAWL_API_KEY", "Firecrawl"),
1528+
("TAVILY_API_KEY", "Tavily"),
15191529
("BROWSERBASE_API_KEY", "Browserbase"),
15201530
("BROWSER_USE_API_KEY", "Browser Use"),
15211531
("FAL_KEY", "FAL"),
@@ -1664,7 +1674,8 @@ def set_config_value(key: str, value: str):
16641674
# Check if it's an API key (goes to .env)
16651675
api_keys = [
16661676
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
1667-
'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
1677+
'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
1678+
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
16681679
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
16691680
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
16701681
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',

hermes_cli/setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,11 @@ def _print_setup_summary(config: dict, hermes_home):
444444
else:
445445
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
446446

447-
# Web tools (Parallel or Firecrawl)
448-
if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"):
447+
# Web tools (Parallel, Firecrawl, or Tavily)
448+
if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
449449
tool_status.append(("Web Search & Extract", True, None))
450450
else:
451-
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY or FIRECRAWL_API_KEY"))
451+
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
452452

453453
# Browser tools (local Chromium or Browserbase cloud)
454454
import shutil

hermes_cli/status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def show_status(args):
120120
"MiniMax": "MINIMAX_API_KEY",
121121
"MiniMax-CN": "MINIMAX_CN_API_KEY",
122122
"Firecrawl": "FIRECRAWL_API_KEY",
123+
"Tavily": "TAVILY_API_KEY",
123124
"Browserbase": "BROWSERBASE_API_KEY", # Optional — local browser works without this
124125
"FAL": "FAL_KEY",
125126
"Tinker": "TINKER_API_KEY",

hermes_cli/tools_config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,14 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
170170
{"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"},
171171
],
172172
},
173+
{
174+
"name": "Tavily",
175+
"tag": "AI-native search, extract, and crawl",
176+
"web_backend": "tavily",
177+
"env_vars": [
178+
{"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"},
179+
],
180+
},
173181
{
174182
"name": "Firecrawl Self-Hosted",
175183
"tag": "Free - run your own instance",
@@ -851,6 +859,11 @@ def _reconfigure_provider(provider: dict, config: dict):
851859
config.get("browser", {}).pop("cloud_provider", None)
852860
_print_success(f" Browser set to local mode")
853861

862+
# Set web search backend in config if applicable
863+
if provider.get("web_backend"):
864+
config.setdefault("web", {})["backend"] = provider["web_backend"]
865+
_print_success(f" Web backend set to: {provider['web_backend']}")
866+
854867
if not env_vars:
855868
_print_success(f" {provider['name']} - no configuration needed!")
856869
return

tests/hermes_cli/test_config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,38 @@ def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
316316
assert fixes == 0
317317

318318

319+
class TestOptionalEnvVarsRegistry:
320+
"""Verify that key env vars are registered in OPTIONAL_ENV_VARS."""
321+
322+
def test_tavily_api_key_registered(self):
323+
"""TAVILY_API_KEY is listed in OPTIONAL_ENV_VARS."""
324+
from hermes_cli.config import OPTIONAL_ENV_VARS
325+
assert "TAVILY_API_KEY" in OPTIONAL_ENV_VARS
326+
327+
def test_tavily_api_key_is_tool_category(self):
328+
"""TAVILY_API_KEY is in the 'tool' category."""
329+
from hermes_cli.config import OPTIONAL_ENV_VARS
330+
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["category"] == "tool"
331+
332+
def test_tavily_api_key_is_password(self):
333+
"""TAVILY_API_KEY is marked as password."""
334+
from hermes_cli.config import OPTIONAL_ENV_VARS
335+
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["password"] is True
336+
337+
def test_tavily_api_key_has_url(self):
338+
"""TAVILY_API_KEY has a URL."""
339+
from hermes_cli.config import OPTIONAL_ENV_VARS
340+
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["url"] == "https://app.tavily.com/home"
341+
342+
def test_tavily_in_env_vars_by_version(self):
343+
"""TAVILY_API_KEY is listed in ENV_VARS_BY_VERSION."""
344+
from hermes_cli.config import ENV_VARS_BY_VERSION
345+
all_vars = []
346+
for vars_list in ENV_VARS_BY_VERSION.values():
347+
all_vars.extend(vars_list)
348+
assert "TAVILY_API_KEY" in all_vars
349+
350+
319351
class TestAnthropicTokenMigration:
320352
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""
321353

tests/hermes_cli/test_status.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from types import SimpleNamespace
2+
3+
from hermes_cli.status import show_status
4+
5+
6+
def test_show_status_includes_tavily_key(monkeypatch, capsys, tmp_path):
7+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
8+
monkeypatch.setenv("TAVILY_API_KEY", "tvly-1234567890abcdef")
9+
10+
show_status(SimpleNamespace(all=False, deep=False))
11+
12+
output = capsys.readouterr().out
13+
assert "Tavily" in output
14+
assert "tvly...cdef" in output

tests/tools/test_web_tools_config.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ class TestBackendSelection:
130130
setups.
131131
"""
132132

133-
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL")
133+
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
134134

135135
def setup_method(self):
136136
for key in self._ENV_KEYS:
@@ -155,12 +155,31 @@ def test_config_firecrawl(self):
155155
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
156156
assert _get_backend() == "firecrawl"
157157

158+
def test_config_tavily(self):
159+
"""web.backend=tavily in config → 'tavily' regardless of other keys."""
160+
from tools.web_tools import _get_backend
161+
with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}):
162+
assert _get_backend() == "tavily"
163+
164+
def test_config_tavily_overrides_env_keys(self):
165+
"""web.backend=tavily in config → 'tavily' even if Firecrawl key set."""
166+
from tools.web_tools import _get_backend
167+
with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}), \
168+
patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
169+
assert _get_backend() == "tavily"
170+
158171
def test_config_case_insensitive(self):
159172
"""web.backend=Parallel (mixed case) → 'parallel'."""
160173
from tools.web_tools import _get_backend
161174
with patch("tools.web_tools._load_web_config", return_value={"backend": "Parallel"}):
162175
assert _get_backend() == "parallel"
163176

177+
def test_config_tavily_case_insensitive(self):
178+
"""web.backend=Tavily (mixed case) → 'tavily'."""
179+
from tools.web_tools import _get_backend
180+
with patch("tools.web_tools._load_web_config", return_value={"backend": "Tavily"}):
181+
assert _get_backend() == "tavily"
182+
164183
# ── Fallback (no web.backend in config) ───────────────────────────
165184

166185
def test_fallback_parallel_only_key(self):
@@ -170,6 +189,28 @@ def test_fallback_parallel_only_key(self):
170189
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
171190
assert _get_backend() == "parallel"
172191

192+
def test_fallback_tavily_only_key(self):
193+
"""Only TAVILY_API_KEY set → 'tavily'."""
194+
from tools.web_tools import _get_backend
195+
with patch("tools.web_tools._load_web_config", return_value={}), \
196+
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
197+
assert _get_backend() == "tavily"
198+
199+
def test_fallback_tavily_with_firecrawl_prefers_firecrawl(self):
200+
"""Tavily + Firecrawl keys, no config → 'firecrawl' (backward compat)."""
201+
from tools.web_tools import _get_backend
202+
with patch("tools.web_tools._load_web_config", return_value={}), \
203+
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "FIRECRAWL_API_KEY": "fc-test"}):
204+
assert _get_backend() == "firecrawl"
205+
206+
def test_fallback_tavily_with_parallel_prefers_parallel(self):
207+
"""Tavily + Parallel keys, no config → 'parallel' (Parallel takes priority over Tavily)."""
208+
from tools.web_tools import _get_backend
209+
with patch("tools.web_tools._load_web_config", return_value={}), \
210+
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "PARALLEL_API_KEY": "par-test"}):
211+
# Parallel + no Firecrawl → parallel
212+
assert _get_backend() == "parallel"
213+
173214
def test_fallback_both_keys_defaults_to_firecrawl(self):
174215
"""Both keys set, no config → 'firecrawl' (backward compat)."""
175216
from tools.web_tools import _get_backend
@@ -193,7 +234,7 @@ def test_fallback_no_keys_defaults_to_firecrawl(self):
193234
def test_invalid_config_falls_through_to_fallback(self):
194235
"""web.backend=invalid → ignored, uses key-based fallback."""
195236
from tools.web_tools import _get_backend
196-
with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}), \
237+
with patch("tools.web_tools._load_web_config", return_value={"backend": "nonexistent"}), \
197238
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
198239
assert _get_backend() == "parallel"
199240

@@ -238,7 +279,7 @@ def test_singleton_returns_same_instance(self):
238279
class TestCheckWebApiKey:
239280
"""Test suite for check_web_api_key() unified availability check."""
240281

241-
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL")
282+
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
242283

243284
def setup_method(self):
244285
for key in self._ENV_KEYS:
@@ -263,6 +304,11 @@ def test_firecrawl_url_only(self):
263304
from tools.web_tools import check_web_api_key
264305
assert check_web_api_key() is True
265306

307+
def test_tavily_key_only(self):
308+
with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
309+
from tools.web_tools import check_web_api_key
310+
assert check_web_api_key() is True
311+
266312
def test_no_keys_returns_false(self):
267313
from tools.web_tools import check_web_api_key
268314
assert check_web_api_key() is False
@@ -274,3 +320,12 @@ def test_both_keys_returns_true(self):
274320
}):
275321
from tools.web_tools import check_web_api_key
276322
assert check_web_api_key() is True
323+
324+
def test_all_three_keys_returns_true(self):
325+
with patch.dict(os.environ, {
326+
"PARALLEL_API_KEY": "test-key",
327+
"FIRECRAWL_API_KEY": "fc-test",
328+
"TAVILY_API_KEY": "tvly-test",
329+
}):
330+
from tools.web_tools import check_web_api_key
331+
assert check_web_api_key() is True

0 commit comments

Comments
 (0)