diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 6d65bf83e..fd2091c51 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -234,7 +234,7 @@ tools: ``` **Built-in Tools**: -- `web_search` - Search the web (DuckDuckGo, Tavily, Exa, InfoQuest, Firecrawl) +- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Firecrawl) - `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl) - `ls` - List directory contents - `read_file` - Read file contents @@ -364,6 +364,7 @@ models: - `MIMO_API_KEY` - Xiaomi MiMo API key - `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint) - `TAVILY_API_KEY` - Tavily search API key +- `BRAVE_SEARCH_API_KEY` - Brave Search API key - `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths - `DEER_FLOW_CONFIG_PATH` - Custom config file path - `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Custom extensions config file path diff --git a/backend/packages/harness/deerflow/community/brave/__init__.py b/backend/packages/harness/deerflow/community/brave/__init__.py new file mode 100644 index 000000000..876167859 --- /dev/null +++ b/backend/packages/harness/deerflow/community/brave/__init__.py @@ -0,0 +1,3 @@ +from .tools import web_search_tool + +__all__ = ["web_search_tool"] diff --git a/backend/packages/harness/deerflow/community/brave/tools.py b/backend/packages/harness/deerflow/community/brave/tools.py new file mode 100644 index 000000000..48df4c03c --- /dev/null +++ b/backend/packages/harness/deerflow/community/brave/tools.py @@ -0,0 +1,119 @@ +""" +Web Search Tool - Search the web using the Brave Search API. + +Brave Search provides web results from an independent search index via a +REST API. An API key is required. Sign up at https://brave.com/search/api/ +to get one. + +Unlike the DuckDuckGo ``backend: brave`` option (which scrapes results via the +DDGS aggregator), this provider calls the official Brave Search API directly, +giving structured results, authenticated quota, and a documented SLA. +""" + +import json +import logging +import os + +import httpx +from langchain.tools import tool + +from deerflow.config import get_app_config + +logger = logging.getLogger(__name__) + +_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search" +_DEFAULT_MAX_RESULTS = 5 +# Brave Search API caps the `count` parameter at 20 results per request. +_BRAVE_MAX_COUNT = 20 +_api_key_warned = False + + +def _get_api_key() -> str | None: + config = get_app_config().get_tool_config("web_search") + if config is not None: + api_key = (config.model_extra or {}).get("api_key") + if isinstance(api_key, str) and api_key.strip(): + return api_key + return os.getenv("BRAVE_SEARCH_API_KEY") + + +def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int: + try: + coerced = int(value) + except (TypeError, ValueError): + logger.warning( + "Invalid Brave Search max_results=%r; using default %s", + value, + default, + ) + coerced = default + + return max(1, min(coerced, _BRAVE_MAX_COUNT)) + + +@tool("web_search", parse_docstring=True) +def web_search_tool(query: str, max_results: int = 5) -> str: + """Search the web for information using Brave Search. + + Args: + query: Search keywords describing what you want to find. Be specific for better results. + max_results: Maximum number of search results to return. Default is 5. + """ + global _api_key_warned + + config = get_app_config().get_tool_config("web_search") + if config is not None and "max_results" in (config.model_extra or {}): + max_results = config.model_extra["max_results"] + + count = _coerce_max_results(max_results) + + api_key = _get_api_key() + if not api_key: + if not _api_key_warned: + _api_key_warned = True + logger.warning("Brave Search API key is not set. Set BRAVE_SEARCH_API_KEY in your environment or provide api_key in config.yaml. Sign up at https://brave.com/search/api/") + return json.dumps( + {"error": "BRAVE_SEARCH_API_KEY is not configured", "query": query}, + ensure_ascii=False, + ) + + headers = { + "X-Subscription-Token": api_key, + "Accept": "application/json", + } + params = {"q": query, "count": count, "text_decorations": False} + + try: + with httpx.Client(timeout=30) as client: + response = client.get(_BRAVE_ENDPOINT, headers=headers, params=params) + response.raise_for_status() + data = response.json() + except httpx.HTTPStatusError as e: + logger.error(f"Brave Search API returned HTTP {e.response.status_code}: {e.response.text}") + return json.dumps( + {"error": f"Brave Search API error: HTTP {e.response.status_code}", "query": query}, + ensure_ascii=False, + ) + except Exception as e: + logger.error(f"Brave search failed: {type(e).__name__}: {e}") + return json.dumps({"error": str(e), "query": query}, ensure_ascii=False) + + web_results = (data.get("web") or {}).get("results", []) + if not web_results: + return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False) + + normalized_results = [ + { + "title": r.get("title", ""), + "url": r.get("url", ""), + "content": r.get("description", ""), + } + for r in web_results + ] + + output = { + "query": query, + "total_results": len(normalized_results), + "results": normalized_results, + } + return json.dumps(output, indent=2, ensure_ascii=False) diff --git a/backend/tests/test_brave_tools.py b/backend/tests/test_brave_tools.py new file mode 100644 index 000000000..c2c50d00f --- /dev/null +++ b/backend/tests/test_brave_tools.py @@ -0,0 +1,360 @@ +"""Unit tests for the Brave Search community web search tool.""" + +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest + + +@pytest.fixture(autouse=True) +def reset_api_key_warned(): + """Reset the module-level warning flag before each test.""" + import deerflow.community.brave.tools as brave_mod + + brave_mod._api_key_warned = False + yield + brave_mod._api_key_warned = False + + +@pytest.fixture +def mock_config_with_key(): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {"api_key": "test-brave-key", "max_results": 5} + mock.return_value.get_tool_config.return_value = tool_config + yield mock + + +@pytest.fixture +def mock_config_no_key(): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {} + mock.return_value.get_tool_config.return_value = tool_config + yield mock + + +def _make_brave_response(results: list) -> MagicMock: + mock_resp = MagicMock() + mock_resp.json.return_value = {"web": {"results": results}} + mock_resp.raise_for_status = MagicMock() + return mock_resp + + +def _count_aware_get(results: list): + """Mimic Brave returning at most `count` results for the request.""" + + def _get(url, **kwargs): + count = kwargs["params"]["count"] + return _make_brave_response(results[:count]) + + return _get + + +class TestGetApiKey: + def test_returns_config_key_when_present(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {"api_key": "from-config"} + mock.return_value.get_tool_config.return_value = tool_config + + from deerflow.community.brave.tools import _get_api_key + + assert _get_api_key() == "from-config" + + def test_falls_back_to_env_when_config_key_empty(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {"api_key": " "} + mock.return_value.get_tool_config.return_value = tool_config + with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True): + from deerflow.community.brave.tools import _get_api_key + + assert _get_api_key() == "env-key" + + def test_falls_back_to_env_when_no_config(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + mock.return_value.get_tool_config.return_value = None + with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-only"}, clear=True): + from deerflow.community.brave.tools import _get_api_key + + assert _get_api_key() == "env-only" + + def test_ignores_legacy_brave_api_key(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + mock.return_value.get_tool_config.return_value = None + with patch.dict("os.environ", {"BRAVE_API_KEY": "legacy"}, clear=True): + from deerflow.community.brave.tools import _get_api_key + + assert _get_api_key() is None + + def test_returns_none_when_no_key_anywhere(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + mock.return_value.get_tool_config.return_value = None + with patch.dict("os.environ", {}, clear=True): + from deerflow.community.brave.tools import _get_api_key + + assert _get_api_key() is None + + def test_model_extra_none_does_not_crash(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = None + mock.return_value.get_tool_config.return_value = tool_config + with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True): + from deerflow.community.brave.tools import _get_api_key + + assert _get_api_key() == "env-key" + + +class TestWebSearchTool: + def test_basic_search_returns_normalized_results(self, mock_config_with_key): + results = [ + {"title": "Result 1", "url": "https://example.com/1", "description": "Desc 1"}, + {"title": "Result 2", "url": "https://example.com/2", "description": "Desc 2"}, + ] + mock_resp = _make_brave_response(results) + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "python tutorial"}) + parsed = json.loads(result) + + assert parsed["query"] == "python tutorial" + assert parsed["total_results"] == 2 + assert parsed["results"][0]["title"] == "Result 1" + assert parsed["results"][0]["url"] == "https://example.com/1" + assert parsed["results"][0]["content"] == "Desc 1" + + def test_respects_max_results_from_config(self, mock_config_with_key): + mock_config_with_key.return_value.get_tool_config.return_value.model_extra = { + "api_key": "test-key", + "max_results": 3, + } + results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)] + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results) + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert parsed["total_results"] == 3 + assert len(parsed["results"]) == 3 + + def test_max_results_parameter_accepted(self, mock_config_no_key): + """Tool accepts max_results as a call parameter when config does not override it.""" + results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)] + + with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True): + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results) + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test", "max_results": 2}) + parsed = json.loads(result) + + assert parsed["total_results"] == 2 + + def test_config_max_results_overrides_parameter(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {"api_key": "test-key", "max_results": 3} + mock.return_value.get_tool_config.return_value = tool_config + + results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)] + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results) + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test", "max_results": 8}) + parsed = json.loads(result) + + assert parsed["total_results"] == 3 + + def test_max_results_string_from_env_is_coerced_and_clamped(self): + """Env-sourced max_results is a string and must be coerced and clamped to 20.""" + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {"api_key": "test-key", "max_results": "50"} + mock.return_value.get_tool_config.return_value = tool_config + + results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(30)] + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_get = mock_client_cls.return_value.__enter__.return_value.get + mock_get.side_effect = _count_aware_get(results) + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + params = mock_get.call_args.kwargs["params"] + + assert params["count"] == 20 + assert parsed["total_results"] == 20 + + def test_invalid_max_results_falls_back_to_default(self, caplog): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + tool_config = MagicMock() + tool_config.model_extra = {"api_key": "test-key", "max_results": "abc"} + mock.return_value.get_tool_config.return_value = tool_config + + results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)] + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_get = mock_client_cls.return_value.__enter__.return_value.get + mock_get.side_effect = _count_aware_get(results) + + from deerflow.community.brave.tools import web_search_tool + + with caplog.at_level("WARNING", logger="deerflow.community.brave.tools"): + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + params = mock_get.call_args.kwargs["params"] + + assert params["count"] == 5 + assert parsed["total_results"] == 5 + assert any("Invalid Brave Search max_results" in record.message for record in caplog.records) + + def test_empty_results_returns_error_json(self, mock_config_with_key): + mock_resp = _make_brave_response([]) + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "no results"}) + parsed = json.loads(result) + + assert parsed["error"] == "No results found" + assert parsed["query"] == "no results" + + def test_missing_web_key_returns_error_json(self, mock_config_with_key): + """A response without a `web` block should be treated as no results.""" + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert parsed["error"] == "No results found" + + def test_missing_api_key_returns_error_json(self, mock_config_no_key): + with patch.dict("os.environ", {}, clear=True): + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert "error" in parsed + assert "BRAVE_SEARCH_API_KEY" in parsed["error"] + + def test_missing_api_key_logs_warning_once(self, mock_config_no_key, caplog): + import logging + + with patch.dict("os.environ", {}, clear=True): + from deerflow.community.brave.tools import web_search_tool + + with caplog.at_level(logging.WARNING, logger="deerflow.community.brave.tools"): + web_search_tool.invoke({"query": "q1"}) + web_search_tool.invoke({"query": "q2"}) + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + + def test_http_error_returns_structured_error(self, mock_config_with_key): + mock_error_response = MagicMock() + mock_error_response.status_code = 403 + mock_error_response.text = "Forbidden" + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.side_effect = httpx.HTTPStatusError("403", request=MagicMock(), response=mock_error_response) + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert "error" in parsed + assert "403" in parsed["error"] + + def test_network_exception_returns_error_json(self, mock_config_with_key): + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.side_effect = Exception("timeout") + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert "error" in parsed + + def test_sends_correct_headers_and_params(self, mock_config_with_key): + results = [{"title": "T", "url": "https://x.com", "description": "D"}] + mock_resp = _make_brave_response(results) + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_get = mock_client_cls.return_value.__enter__.return_value.get + mock_get.return_value = mock_resp + + from deerflow.community.brave.tools import web_search_tool + + web_search_tool.invoke({"query": "hello world"}) + + call_kwargs = mock_get.call_args + headers = call_kwargs.kwargs["headers"] + params = call_kwargs.kwargs["params"] + + assert headers["X-Subscription-Token"] == "test-brave-key" + assert params["q"] == "hello world" + assert params["count"] == 5 + + def test_uses_env_key_when_config_absent(self): + with patch("deerflow.community.brave.tools.get_app_config") as mock: + mock.return_value.get_tool_config.return_value = None + with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-only-key"}, clear=True): + results = [{"title": "T", "url": "https://x.com", "description": "D"}] + mock_resp = _make_brave_response(results) + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_get = mock_client_cls.return_value.__enter__.return_value.get + mock_get.return_value = mock_resp + + from deerflow.community.brave.tools import web_search_tool + + web_search_tool.invoke({"query": "env key test"}) + headers = mock_get.call_args.kwargs["headers"] + + assert headers["X-Subscription-Token"] == "env-only-key" + + def test_partial_fields_in_result(self, mock_config_with_key): + """Missing title/url/description should default to empty string.""" + results = [{}] + mock_resp = _make_brave_response(results) + + with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp + + from deerflow.community.brave.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert parsed["results"][0] == {"title": "", "url": "", "content": ""} diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index 5e2102792..eed56e227 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -198,6 +198,38 @@ class TestCheckWebSearch: assert result.fix is not None assert "make setup" in result.fix + def test_brave_with_key_ok(self, tmp_path, monkeypatch): + monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test") + cfg = tmp_path / "config.yaml" + cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n") + result = doctor.check_web_search(cfg) + assert result.status == "ok" + + def test_brave_without_key_warns(self, tmp_path, monkeypatch): + monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False) + cfg = tmp_path / "config.yaml" + cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n") + result = doctor.check_web_search(cfg) + assert result.status == "warn" + assert result.fix is not None + assert "BRAVE_SEARCH_API_KEY" in result.fix + + def test_brave_with_inline_api_key_ok(self, tmp_path, monkeypatch): + monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False) + cfg = tmp_path / "config.yaml" + cfg.write_text('config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n api_key: "inline-key"\n') + result = doctor.check_web_search(cfg) + assert result.status == "ok" + assert "api_key configured" in result.detail + + def test_brave_with_api_key_env_ref_ok(self, tmp_path, monkeypatch): + monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test") + cfg = tmp_path / "config.yaml" + cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n api_key: $BRAVE_SEARCH_API_KEY\n") + result = doctor.check_web_search(cfg) + assert result.status == "ok" + assert "api_key" in result.detail + def test_no_search_tool_warns(self, tmp_path): cfg = tmp_path / "config.yaml" cfg.write_text("config_version: 5\ntools: []\n") diff --git a/config.example.yaml b/config.example.yaml index 86643c917..5f9ff9a82 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -530,6 +530,16 @@ tools: # max_results: 5 # # api_key: $SERPER_API_KEY # Optional if SERPER_API_KEY env var is set + # Web search tool (uses Brave Search API, requires BRAVE_SEARCH_API_KEY) + # Brave Search returns results from an independent index. Sign up at + # https://brave.com/search/api/ to get a key. Unlike the DuckDuckGo + # `backend: brave` option above, this calls the official Brave API directly. + # - name: web_search + # group: web + # use: deerflow.community.brave.tools:web_search_tool + # max_results: 5 # Capped at 20 by the Brave Search API + # # api_key: $BRAVE_SEARCH_API_KEY # Optional if the env var is set + # Web search tool (requires Tavily API key) # - name: web_search # group: web diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx index 1abc7f6fe..bde4c43b2 100644 --- a/frontend/src/content/en/harness/tools.mdx +++ b/frontend/src/content/en/harness/tools.mdx @@ -113,7 +113,7 @@ Community tools connect the agent to external services. They are configured in ` ### Web search - + ```yaml tools: @@ -134,6 +134,17 @@ Install: `cd backend && uv add 'deerflow-harness[tavily]'` ```yaml +tools: + - use: deerflow.community.brave.tools:web_search_tool + api_key: $BRAVE_SEARCH_API_KEY +``` +Results from Brave's independent index via the official [Brave Search API](https://brave.com/search/api/). Requires an API key. `max_results` is capped at 20. + +No extra dependency required. + + + +```yaml tools: - use: deerflow.community.exa.tools:web_search_tool api_key: $EXA_API_KEY diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx index a8d158b21..190b6e7ec 100644 --- a/frontend/src/content/zh/harness/tools.mdx +++ b/frontend/src/content/zh/harness/tools.mdx @@ -110,7 +110,7 @@ tools: ### 网络搜索 - + ```yaml tools: @@ -131,6 +131,17 @@ tools: ```yaml +tools: + - use: deerflow.community.brave.tools:web_search_tool + api_key: $BRAVE_SEARCH_API_KEY +``` +通过官方 [Brave Search API](https://brave.com/search/api/) 返回 Brave 独立索引的结果。需要 API Key,`max_results` 上限为 20。 + +无需额外依赖。 + + + +```yaml tools: - use: deerflow.community.exa.tools:web_search_tool api_key: $EXA_API_KEY @@ -142,6 +153,14 @@ tools: ```yaml +tools: + - use: deerflow.community.infoquest.tools:web_search_tool + api_key: $INFOQUEST_API_KEY +``` +InfoQuest 搜索集成。 + + +```yaml tools: - use: deerflow.community.firecrawl.tools:web_search_tool api_key: $FIRECRAWL_API_KEY diff --git a/scripts/doctor.py b/scripts/doctor.py index 8d870c7c4..cd248b144 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -457,8 +457,9 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes data = _load_yaml_file(config_path) - tool_uses = [t.get("use", "") for t in data.get("tools", []) if t.get("name") == tool_name] - if not tool_uses: + tool_entries = [t for t in data.get("tools", []) if t.get("name") == tool_name] + tool_uses = [t.get("use", "") for t in tool_entries] + if not tool_entries: return CheckResult( label, "warn", @@ -476,6 +477,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes "infoquest": "INFOQUEST_API_KEY", "exa": "EXA_API_KEY", "firecrawl": "FIRECRAWL_API_KEY", + "brave": "BRAVE_SEARCH_API_KEY", }, "web_fetch": { "infoquest": "INFOQUEST_API_KEY", @@ -484,14 +486,24 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes }, } - for use in tool_uses: + for tool_entry in tool_entries: + use = tool_entry.get("use", "") for provider, detail in free_providers.get(tool_name, {}).items(): if provider in use: return CheckResult(label, "ok", detail) - for use in tool_uses: + for tool_entry in tool_entries: + use = tool_entry.get("use", "") for provider, var in key_providers.get(tool_name, {}).items(): if provider in use: + configured_key = tool_entry.get("api_key") + if isinstance(configured_key, str) and configured_key.strip(): + if configured_key.startswith("$"): + ref_var = configured_key[1:] + if os.environ.get(ref_var): + return CheckResult(label, "ok", f"{provider} ({ref_var} set via api_key)") + else: + return CheckResult(label, "ok", f"{provider} (api_key configured)") val = os.environ.get(var) if val: return CheckResult(label, "ok", f"{provider} ({var} set)") diff --git a/scripts/wizard/providers.py b/scripts/wizard/providers.py index 013fbd83b..eb38996cf 100644 --- a/scripts/wizard/providers.py +++ b/scripts/wizard/providers.py @@ -510,6 +510,14 @@ SEARCH_PROVIDERS: list[SearchProvider] = [ env_var="FIRECRAWL_API_KEY", extra_config={"max_results": 5}, ), + SearchProvider( + name="brave", + display_name="Brave Search", + description="Independent index, official API, API key required", + use="deerflow.community.brave.tools:web_search_tool", + env_var="BRAVE_SEARCH_API_KEY", + extra_config={"max_results": 5}, + ), ] WEB_FETCH_PROVIDERS: list[WebProvider] = [