From d403cf018c8e6a887e5b867bf6de76cc4aadacd9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:30:31 +0530 Subject: [PATCH] feat(web): brave_free plugin (first migration from tools/web_providers/) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plugins/web/brave_free/ as the first plugin built against the new WebSearchProvider ABC. Mirrors the plugins/image_gen/openai/ layout exactly: plugins/web/brave_free/ plugin.yaml kind: backend, provides_web_providers: [brave-free] __init__.py register(ctx) -> ctx.register_web_search_provider(...) provider.py BraveFreeWebSearchProvider(WebSearchProvider) Behavior preserved: same name ("brave-free" with hyphen), same env var (BRAVE_SEARCH_API_KEY), same HTTP request shape, same response normalization. The legacy tools/web_providers/brave_free.py is left in place — the dispatcher in tools/web_tools.py still references it. Task 7 cuts over the dispatcher to the new registry; Task 10 deletes the legacy file. E2E verified: HERMES_PLUGINS_DEBUG=1 python -c " from hermes_cli.plugins import _ensure_plugins_discovered _ensure_plugins_discovered() from agent.web_search_registry import list_providers print([p.name for p in list_providers()]) " # -> ['brave-free'] --- plugins/web/__init__.py | 7 ++ plugins/web/brave_free/__init__.py | 14 +++ plugins/web/brave_free/plugin.yaml | 7 ++ plugins/web/brave_free/provider.py | 137 +++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 plugins/web/__init__.py create mode 100644 plugins/web/brave_free/__init__.py create mode 100644 plugins/web/brave_free/plugin.yaml create mode 100644 plugins/web/brave_free/provider.py diff --git a/plugins/web/__init__.py b/plugins/web/__init__.py new file mode 100644 index 00000000000..ad557e17744 --- /dev/null +++ b/plugins/web/__init__.py @@ -0,0 +1,7 @@ +# Bundled web search providers — plugins/web/. +# +# Each subdirectory follows the image_gen plugin layout: +# plugins/web//{plugin.yaml, __init__.py, provider.py} +# +# They auto-load via kind: backend and register via +# ctx.register_web_search_provider() into agent.web_search_registry. diff --git a/plugins/web/brave_free/__init__.py b/plugins/web/brave_free/__init__.py new file mode 100644 index 00000000000..6499d546722 --- /dev/null +++ b/plugins/web/brave_free/__init__.py @@ -0,0 +1,14 @@ +"""Brave Search (free tier) plugin — bundled, auto-loaded. + +Mirrors the ``plugins/image_gen/openai/`` layout: ``provider.py`` holds the +provider class, ``__init__.py::register(ctx)`` registers an instance. +""" + +from __future__ import annotations + +from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + + +def register(ctx) -> None: + """Register the Brave-free provider with the plugin context.""" + ctx.register_web_search_provider(BraveFreeWebSearchProvider()) diff --git a/plugins/web/brave_free/plugin.yaml b/plugins/web/brave_free/plugin.yaml new file mode 100644 index 00000000000..3b39a34e18d --- /dev/null +++ b/plugins/web/brave_free/plugin.yaml @@ -0,0 +1,7 @@ +name: web-brave-free +version: 1.0.0 +description: "Brave Search (free tier) — web search via Brave's Data-for-Search API. Requires BRAVE_SEARCH_API_KEY (free signup at https://brave.com/search/api/, 2k queries/month)." +author: NousResearch +kind: backend +provides_web_providers: + - brave-free diff --git a/plugins/web/brave_free/provider.py b/plugins/web/brave_free/provider.py new file mode 100644 index 00000000000..dfa927ef10e --- /dev/null +++ b/plugins/web/brave_free/provider.py @@ -0,0 +1,137 @@ +"""Brave Search (free tier) — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider` (the +plugin-facing ABC) and reuses the existing Brave search logic from the +legacy ``tools.web_providers.brave_free`` module. Once the spike validates +the pattern, the legacy module is deleted and this becomes the canonical +implementation. + +Config keys this provider responds to:: + + web: + search_backend: "brave-free" # explicit per-capability + backend: "brave-free" # shared fallback + +Auth env var:: + + BRAVE_SEARCH_API_KEY=... # https://brave.com/search/api/ (free tier) +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search" + + +class BraveFreeWebSearchProvider(WebSearchProvider): + """Search-only Brave provider using the free-tier Data-for-Search API. + + Free tier is 2,000 queries/month (1 qps). No content-extraction capability — + users pair this with Firecrawl/Tavily/Exa for ``web_extract``. + """ + + @property + def name(self) -> str: + # Hyphen form preserved for backward compat with the existing + # ``web.search_backend: "brave-free"`` config keys users have set. + return "brave-free" + + @property + def display_name(self) -> str: + return "Brave Search (Free)" + + def is_available(self) -> bool: + """Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a search against the Brave Search API. + + Returns ``{"success": True, "data": {"web": [{"title", "url", "description", "position"}]}}`` + on success, or ``{"success": False, "error": str}`` on failure. + """ + import httpx + + api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"} + + # Brave's `count` is capped at 20. + count = max(1, min(int(limit), 20)) + + try: + resp = httpx.get( + _BRAVE_ENDPOINT, + params={"q": query, "count": count}, + headers={ + "X-Subscription-Token": api_key, + "Accept": "application/json", + }, + timeout=15, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("Brave Search HTTP error: %s", exc) + return { + "success": False, + "error": f"Brave Search returned HTTP {exc.response.status_code}", + } + except httpx.RequestError as exc: + logger.warning("Brave Search request error: %s", exc) + return {"success": False, "error": f"Could not reach Brave Search: {exc}"} + + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.warning("Brave Search response parse error: %s", exc) + return {"success": False, "error": "Could not parse Brave Search response as JSON"} + + raw_results = (data.get("web") or {}).get("results", []) or [] + truncated = raw_results[:limit] + + web_results = [ + { + "title": str(r.get("title", "")), + "url": str(r.get("url", "")), + "description": str(r.get("description", "")), + "position": i + 1, + } + for i, r in enumerate(truncated) + ] + + logger.info( + "Brave Search '%s': %d results (from %d raw, limit %d)", + query, + len(web_results), + len(raw_results), + limit, + ) + + return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Brave Search (Free)", + "badge": "free", + "tag": "Free-tier API key — 2k queries/mo, search only.", + "env_vars": [ + { + "key": "BRAVE_SEARCH_API_KEY", + "prompt": "Brave Search API key (free tier)", + "url": "https://brave.com/search/api/", + }, + ], + }