From 733f88cf475bd78c7363699c532b94e67810d7be Mon Sep 17 00:00:00 2001 From: serply Date: Sun, 13 Sep 2026 14:34:41 -0400 Subject: [PATCH] feat(search): add Serply as a web search provider - Extend SearchSettings with serplyApiKey and wire "serply" into the provider factory, the search settings persistence, and the Settings > Search page (Get API key link plus the required-key semantics; Serply has no anonymous tier). - Add SerplySearchProvider, a plain REST client for Google SERP results. num is clamped to a single result page (10) and the response is sliced to the caller's limit, and the body is parsed defensively so a CDN HTML error page surfaces the HTTP status instead of a JSON syntax error. - web_fetch on Serply delegates to Firecrawl for safe page extraction, matching the Brave provider; Serply exposes no single-page extraction endpoint. - Mirror the provider in the LangGraph generator so a generated project runs the same backend: _serply_search in the embedded web_search.py, SERPLY_API_KEY in the generated .env/.env.example, and the literal-key check that decides whether .env is written at all. - Add en/zh labels for the provider and its key. - Cover the new paths with mock-fetch tests (endpoint and auth header, result normalization, the page cap, a missing key, an error detail, a non-JSON body, and web_fetch delegation) plus generator env-block regression tests. --- .../bun/remote/remote-runtime-client.test.ts | 1 + .../src/components/settings/search-page.tsx | 6 +- apps/desktop/src/i18n/messages.ts | 4 + apps/server/src/rpc.test.ts | 1 + .../core/src/generator/langgraph/index.ts | 9 +- .../core/src/generator/langgraph/templates.ts | 4 +- .../tools/built-in-sources.generated.ts | 2 +- .../langgraph/tools/built-in/web_search.py | 57 +++- packages/core/src/types/search.ts | 8 +- .../generator/langgraph/templates.test.ts | 49 +++- .../src/search/search-settings-manager.ts | 6 + packages/runtime/src/tools/built-in/web.ts | 95 +++++++ .../built-in/built-in-tools-module.test.ts | 6 + .../runtime/tests/tools/built-in/web.test.ts | 253 ++++++++++++++++++ .../codegen/generate-project-button.tsx | 1 + 15 files changed, 492 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/bun/remote/remote-runtime-client.test.ts b/apps/desktop/src/bun/remote/remote-runtime-client.test.ts index f85ec2d4..398c612d 100644 --- a/apps/desktop/src/bun/remote/remote-runtime-client.test.ts +++ b/apps/desktop/src/bun/remote/remote-runtime-client.test.ts @@ -199,6 +199,7 @@ describe("RemoteRuntimeClient", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }); } ); diff --git a/apps/desktop/src/components/settings/search-page.tsx b/apps/desktop/src/components/settings/search-page.tsx index 4d78d8bb..4e73e06d 100644 --- a/apps/desktop/src/components/settings/search-page.tsx +++ b/apps/desktop/src/components/settings/search-page.tsx @@ -25,6 +25,7 @@ const PROVIDER_ORDER: readonly SearchProviderId[] = [ "exa", "anysearch", "zhihu", + "serply", ]; /** Where each provider's key is issued, for the "Get API key" link. */ @@ -35,6 +36,7 @@ const FAVICON_DOMAINS: Record = { exa: "exa.ai", anysearch: "anysearch.com", zhihu: "zhihu.com", + serply: "serply.io", }; const GET_KEY_URLS: Record = { @@ -44,6 +46,7 @@ const GET_KEY_URLS: Record = { exa: "https://dashboard.exa.ai/api-keys", anysearch: "https://www.anysearch.com/console/api-keys", zhihu: "https://developer.zhihu.com/", + serply: "https://serply.io", }; export function SearchPage({ runtimeId }: { runtimeId: RuntimeId }) { @@ -237,7 +240,8 @@ function _settingsKeyFor( | "tavilyApiKey" | "exaApiKey" | "anysearchApiKey" - | "zhihuAccessSecret" { + | "zhihuAccessSecret" + | "serplyApiKey" { return provider === "zhihu" ? "zhihuAccessSecret" : `${provider}ApiKey`; } diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 07372ad8..8348c837 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -699,6 +699,7 @@ const APP_MESSAGES = { exa: "Exa", anysearch: "AnySearch", zhihu: "Zhihu", + serply: "Serply", }, keys: { brave: "Brave Search API key", @@ -707,6 +708,7 @@ const APP_MESSAGES = { exa: "Exa API key (optional)", anysearch: "AnySearch API key (optional)", zhihu: "Zhihu Access Secret", + serply: "Serply API key", }, envPrefix: "Values starting with ", envMiddle: " are read from the environment (e.g. ", @@ -1359,6 +1361,7 @@ const APP_MESSAGES = { exa: "Exa", anysearch: "AnySearch", zhihu: "知乎", + serply: "Serply", }, keys: { brave: "Brave Search API Key", @@ -1367,6 +1370,7 @@ const APP_MESSAGES = { exa: "Exa API Key(可选)", anysearch: "AnySearch API Key(可选)", zhihu: "知乎 Access Secret", + serply: "Serply API Key", }, envPrefix: "以 ", envMiddle: " 开头的值会从环境变量读取(例如 ", diff --git a/apps/server/src/rpc.test.ts b/apps/server/src/rpc.test.ts index 9d55b6cc..1873ed4a 100644 --- a/apps/server/src/rpc.test.ts +++ b/apps/server/src/rpc.test.ts @@ -54,6 +54,7 @@ function createRuntime(): RuntimeClient { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), getNetworkSettings: () => ({ enabled: false, diff --git a/packages/core/src/generator/langgraph/index.ts b/packages/core/src/generator/langgraph/index.ts index 61ee0dcb..a81142bb 100644 --- a/packages/core/src/generator/langgraph/index.ts +++ b/packages/core/src/generator/langgraph/index.ts @@ -91,9 +91,12 @@ function _hasLiteralSecret( if (!search) { return false; } - return [search.firecrawlApiKey, search.tavilyApiKey, search.braveApiKey].some( - (v) => v && !v.startsWith("$") - ); + return [ + search.firecrawlApiKey, + search.tavilyApiKey, + search.braveApiKey, + search.serplyApiKey, + ].some((v) => v && !v.startsWith("$")); } /** diff --git a/packages/core/src/generator/langgraph/templates.ts b/packages/core/src/generator/langgraph/templates.ts index 7aa46395..8fdbf1ba 100644 --- a/packages/core/src/generator/langgraph/templates.ts +++ b/packages/core/src/generator/langgraph/templates.ts @@ -289,7 +289,7 @@ function _searchEnvBlock(search: SearchSettings, withValues: boolean): string { withValues ? _searchKeyLiteral(value) : ""; return ` # Web-search backend for the built-in web_search / web_fetch tools: -# one of firecrawl, tavily, or brave. +# one of firecrawl, tavily, brave, or serply. SEARCH_PROVIDER=${search.provider} # Optional — Firecrawl's free tier works without a key. FIRECRAWL_API_KEY=${key(search.firecrawlApiKey)} @@ -297,6 +297,8 @@ FIRECRAWL_API_KEY=${key(search.firecrawlApiKey)} TAVILY_API_KEY=${key(search.tavilyApiKey)} # Required only when SEARCH_PROVIDER=brave. BRAVE_API_KEY=${key(search.braveApiKey)} +# Required only when SEARCH_PROVIDER=serply. +SERPLY_API_KEY=${key(search.serplyApiKey)} `; } diff --git a/packages/core/src/generator/langgraph/tools/built-in-sources.generated.ts b/packages/core/src/generator/langgraph/tools/built-in-sources.generated.ts index 3e490d56..81beaa5e 100644 --- a/packages/core/src/generator/langgraph/tools/built-in-sources.generated.ts +++ b/packages/core/src/generator/langgraph/tools/built-in-sources.generated.ts @@ -21,7 +21,7 @@ export const BUILTIN_TOOL_SOURCES: Record = { "tree": "import os\n\nfrom langchain.tools import tool\n\n# Directory and file names the traversal tools (`ls`, `glob`, `grep`) skip by\n# default — dependency, version-control, build-output, and OS-cruft entries that\n# add noise without signal.\nDEFAULT_IGNORES = [\n \"node_modules\",\n \".git\",\n \".svn\",\n \".hg\",\n \".DS_Store\",\n \"Thumbs.db\",\n \".cache\",\n \".next\",\n \".nuxt\",\n \".turbo\",\n \".parcel-cache\",\n \"dist\",\n \"build\",\n \"out\",\n \"coverage\",\n \"__pycache__\",\n \".pytest_cache\",\n \".mypy_cache\",\n \".venv\",\n \"venv\",\n]\n\nIGNORED_NAMES = set(DEFAULT_IGNORES)\n\n# Default and safety-cap depths for `tree`.\nTREE_DEFAULT_DEPTH = 5\nTREE_MAX_DEPTH = 20\n\n\ndef _is_ignored(name: str) -> bool:\n \"\"\"Whether a single entry name should be ignored.\"\"\"\n return name in IGNORED_NAMES\n\n\ndef _build_tree(\n directory: str, prefix: str, remaining: int, lines: list[str]\n) -> None:\n \"\"\"Append a directory's children to ``lines`` as tree rows.\n\n Recurses up to ``remaining`` more levels. Directories sort before files,\n ignored names are skipped, and symlinks are not descended into (avoiding\n loops).\n \"\"\"\n if remaining <= 0:\n return\n try:\n entries = list(os.scandir(directory))\n except OSError:\n # Unreadable directory (permissions, race) — render it as a leaf.\n return\n visible = sorted(\n (e for e in entries if not _is_ignored(e.name)),\n key=lambda e: (0 if e.is_dir() else 1, e.name),\n )\n\n for i, entry in enumerate(visible):\n last = i == len(visible) - 1\n is_dir = entry.is_dir()\n connector = \"└── \" if last else \"├── \"\n suffix = \"/\" if is_dir else \"\"\n lines.append(f\"{prefix}{connector}{entry.name}{suffix}\")\n if is_dir:\n child_prefix = f\"{prefix}{' ' if last else '│ '}\"\n _build_tree(\n os.path.join(directory, entry.name),\n child_prefix,\n remaining - 1,\n lines,\n )\n\n\n@tool\ndef tree(description: str, path: str, max_depth: int = TREE_DEFAULT_DEPTH) -> str:\n \"\"\"Prints a directory as an indented tree up to a maximum depth.\n\n Prints a directory as an indented tree up to a maximum depth (default 5\n levels). Common noise directories (node_modules, .git, build output, etc.)\n are skipped. Use to understand a project's layout at a glance before reading\n individual files.\n\n Args:\n description: Must be the first parameter in the tool call. A short\n human-readable summary explaining why this tree is being generated.\n path: Absolute path to the directory to print as a tree. A leading ~/\n is expanded to the current user's home directory.\n max_depth: Maximum directory depth to descend. Defaults to 5, capped at\n 20.\n \"\"\"\n path = os.path.expanduser(path)\n if not os.path.isdir(path):\n raise ValueError(f\"{path} is not a directory.\")\n if max_depth is not None and max_depth > 0:\n depth = min(int(max_depth), TREE_MAX_DEPTH)\n else:\n depth = TREE_DEFAULT_DEPTH\n\n lines = [path]\n _build_tree(path, \"\", depth, lines)\n if len(lines) == 1:\n return f\"{path} is empty.\"\n return \"\\n\".join(lines)\n", "weather_report": "from urllib.parse import quote\n\nimport requests\nfrom langchain.tools import tool\n\n\ndef _encode_wttr_city(city: str) -> str:\n parts = city.strip().split()\n return \"+\".join(quote(part) for part in parts)\n\n\ndef _get_weather_description(data: dict) -> str:\n weather = data.get(\"weather\") or []\n today = weather[0] if weather else None\n\n hourly = (today or {}).get(\"hourly\") or []\n noon = next((item for item in hourly if item.get(\"time\") == \"1200\"), None)\n if noon:\n noon_desc_list = noon.get(\"weatherDesc\") or []\n noon_desc = noon_desc_list[0].get(\"value\") if noon_desc_list else None\n if noon_desc:\n return noon_desc\n\n current = data.get(\"current_condition\") or []\n if current:\n current_desc_list = current[0].get(\"weatherDesc\") or []\n current_desc = current_desc_list[0].get(\"value\") if current_desc_list else None\n if current_desc:\n return current_desc\n\n return \"Unknown\"\n\n\n@tool\ndef weather_report(location: str) -> dict:\n \"\"\"Get today's weather report for a location.\n\n Get today's weather report for a location.\n\n Args:\n location: The location to get today's weather report for.\n \"\"\"\n normalized_location = location.strip()\n if not normalized_location:\n raise ValueError(\"location is required.\")\n encoded_location = _encode_wttr_city(normalized_location)\n\n res = requests.get(\n f\"https://wttr.in/{encoded_location}?format=j1&lang=en\",\n headers={\n \"Accept\": \"application/json\",\n \"Accept-Language\": \"en\",\n \"User-Agent\": \"llm-space-weather-tool/1.0\",\n },\n )\n\n if not res.ok:\n raise RuntimeError(f\"weather_report failed: {res.status_code}\")\n\n data = res.json()\n weather = data.get(\"weather\") or []\n today = weather[0] if weather else None\n\n if (\n not today\n or not today.get(\"date\")\n or not today.get(\"maxtempC\")\n or not today.get(\"mintempC\")\n ):\n raise RuntimeError(\"weather_report failed: missing today's forecast\")\n\n return {\n \"city\": normalized_location,\n \"date\": today[\"date\"],\n \"weather\": _get_weather_description(data),\n \"temperature\": {\n \"unit\": \"celsius\",\n \"max\": int(today[\"maxtempC\"]),\n \"min\": int(today[\"mintempC\"]),\n },\n }\n", "web_fetch": "import os\n\nimport requests\nfrom langchain.tools import tool\n\nFIRECRAWL_BASE_URL = \"https://api.firecrawl.dev\"\nTAVILY_BASE_URL = \"https://api.tavily.com\"\n\n\ndef _truncate_text(text: str, max_chars: int) -> str:\n if len(text) <= max_chars:\n return text\n return text[:max_chars] + \"\\n\\n[Content truncated]\"\n\n\ndef _firecrawl_fetch(url: str) -> dict:\n \"\"\"Scrape one page to markdown via Firecrawl. The free, unauthenticated tier\n works without a key; ``FIRECRAWL_API_KEY`` upgrades to the authenticated one.\"\"\"\n headers = {\"Content-Type\": \"application/json\"}\n api_key = os.environ.get(\"FIRECRAWL_API_KEY\")\n if api_key:\n headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n res = requests.post(\n f\"{FIRECRAWL_BASE_URL}/v2/scrape\",\n headers=headers,\n json={\"url\": url, \"formats\": [\"markdown\"], \"onlyMainContent\": True},\n )\n json_body = res.json()\n if not res.ok or json_body.get(\"error\"):\n raise RuntimeError(json_body.get(\"error\") or f\"web_fetch failed: {res.status_code}\")\n\n data = json_body.get(\"data\") or {}\n metadata = data.get(\"metadata\") or {}\n title = metadata.get(\"title\")\n return {\n \"url\": url,\n \"title\": title if isinstance(title, str) else None,\n \"content\": _truncate_text(data.get(\"markdown\") or data.get(\"html\") or \"\", 20_000),\n \"metadata\": metadata,\n }\n\n\ndef _tavily_fetch(url: str) -> dict:\n \"\"\"Extract one page to markdown via Tavily. Requires ``TAVILY_API_KEY``.\"\"\"\n api_key = os.environ.get(\"TAVILY_API_KEY\")\n if not api_key:\n raise RuntimeError(\"Tavily API key is not configured. Set TAVILY_API_KEY.\")\n\n res = requests.post(\n f\"{TAVILY_BASE_URL}/extract\",\n headers={\"Content-Type\": \"application/json\", \"Authorization\": f\"Bearer {api_key}\"},\n json={\"urls\": url, \"format\": \"markdown\"},\n )\n if not res.ok:\n raise RuntimeError(f\"web_fetch failed: {res.status_code}\")\n\n json_body = res.json()\n result = (json_body.get(\"results\") or [None])[0]\n if not result or not result.get(\"raw_content\"):\n failed = (json_body.get(\"failed_results\") or [None])[0]\n message = (failed or {}).get(\"error\") if failed else None\n raise RuntimeError(message or f\"web_fetch failed: could not extract {url}\")\n\n return {\n \"url\": result.get(\"url\") or url,\n \"title\": None,\n \"content\": _truncate_text(result[\"raw_content\"], 20_000),\n \"metadata\": {},\n }\n\n\n@tool\ndef web_fetch(url: str) -> dict:\n \"\"\"Fetch one webpage and return LLM-friendly readable markdown content.\n\n Fetch one webpage and return LLM-friendly readable markdown content.\n\n The backend is chosen by the ``SEARCH_PROVIDER`` environment variable\n (``firecrawl`` by default, or ``tavily``/``brave``). Brave exposes no\n single-page extraction endpoint, so under ``brave`` the fetch falls back to\n Firecrawl.\n\n Args:\n url: The URL to fetch. Must be a fully qualified URL starting with\n http:// or https://.\n \"\"\"\n provider = os.environ.get(\"SEARCH_PROVIDER\", \"firecrawl\").strip().lower()\n if provider == \"tavily\":\n return _tavily_fetch(url)\n # `brave` has no extraction endpoint, so it delegates to Firecrawl (matching\n # the desktop behavior, which avoids fetching arbitrary URLs from the trusted\n # process and widening the SSRF surface).\n return _firecrawl_fetch(url)\n", - "web_search": "import os\n\nimport requests\nfrom langchain.tools import tool\n\nFIRECRAWL_BASE_URL = \"https://api.firecrawl.dev\"\nTAVILY_BASE_URL = \"https://api.tavily.com\"\nBRAVE_SEARCH_URL = \"https://api.search.brave.com/res/v1/web/search\"\n\n\ndef _truncate_text(text: str, max_chars: int) -> str:\n if len(text) <= max_chars:\n return text\n return text[:max_chars] + \"\\n\\n[Content truncated]\"\n\n\ndef _firecrawl_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Firecrawl web search. The free, unauthenticated tier works without a key;\n ``FIRECRAWL_API_KEY`` upgrades to the authenticated one.\"\"\"\n headers = {\"Content-Type\": \"application/json\"}\n api_key = os.environ.get(\"FIRECRAWL_API_KEY\")\n if api_key:\n headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n res = requests.post(\n f\"{FIRECRAWL_BASE_URL}/v2/search\",\n headers=headers,\n json={\n \"query\": query,\n \"limit\": limit,\n \"scrapeOptions\": {\"formats\": [\"markdown\"], \"onlyMainContent\": True},\n },\n )\n json_body = res.json()\n if not res.ok or json_body.get(\"error\"):\n raise RuntimeError(json_body.get(\"error\") or f\"web_search failed: {res.status_code}\")\n\n web_results = (json_body.get(\"data\") or {}).get(\"web\") or []\n results = []\n for item in web_results:\n markdown = item.get(\"markdown\")\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"url\") or \"\",\n \"snippet\": item.get(\"description\"),\n \"content\": _truncate_text(markdown, 2_000)\n if include_content and markdown\n else None,\n }\n )\n return results\n\n\ndef _tavily_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Tavily web search. Requires ``TAVILY_API_KEY`` (no free tier).\"\"\"\n api_key = os.environ.get(\"TAVILY_API_KEY\")\n if not api_key:\n raise RuntimeError(\"Tavily API key is not configured. Set TAVILY_API_KEY.\")\n\n res = requests.post(\n f\"{TAVILY_BASE_URL}/search\",\n headers={\"Content-Type\": \"application/json\", \"Authorization\": f\"Bearer {api_key}\"},\n json={\n \"query\": query,\n \"max_results\": limit,\n \"include_raw_content\": \"markdown\" if include_content else False,\n },\n )\n if not res.ok:\n raise RuntimeError(f\"web_search failed: {res.status_code}\")\n\n results = []\n for item in res.json().get(\"results\") or []:\n raw_content = item.get(\"raw_content\")\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"url\") or \"\",\n \"snippet\": item.get(\"content\"),\n \"content\": _truncate_text(raw_content, 2_000)\n if include_content and raw_content\n else None,\n }\n )\n return results\n\n\ndef _brave_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Brave web search. Requires ``BRAVE_API_KEY`` (no free tier).\"\"\"\n api_key = os.environ.get(\"BRAVE_API_KEY\")\n if not api_key:\n raise RuntimeError(\"Brave Search API key is not configured. Set BRAVE_API_KEY.\")\n\n params = {\n \"q\": query,\n \"count\": str(max(1, min(20, limit))),\n \"text_decorations\": \"false\",\n }\n if include_content:\n params[\"extra_snippets\"] = \"true\"\n\n res = requests.get(\n BRAVE_SEARCH_URL,\n headers={\"Accept\": \"application/json\", \"X-Subscription-Token\": api_key},\n params=params,\n )\n json_body = res.json()\n if not res.ok:\n error = json_body.get(\"error\") or {}\n raise RuntimeError(\n error.get(\"detail\")\n or json_body.get(\"message\")\n or json_body.get(\"detail\")\n or f\"web_search failed: {res.status_code}\"\n )\n\n results = []\n for item in (json_body.get(\"web\") or {}).get(\"results\") or []:\n snippets = \"\\n\\n\".join(\n s for s in [item.get(\"description\"), *(item.get(\"extra_snippets\") or [])] if s\n )\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"url\") or \"\",\n \"snippet\": item.get(\"description\"),\n \"content\": _truncate_text(snippets, 2_000)\n if include_content and snippets\n else None,\n }\n )\n return results\n\n\n@tool\ndef web_search(query: str, limit: int = 5, includeContent: bool = False) -> list[dict]:\n \"\"\"Search the web and return LLM-friendly results.\n\n Search the web and return LLM-friendly results.\n\n The backend is chosen by the ``SEARCH_PROVIDER`` environment variable\n (``firecrawl`` by default, or ``tavily``/``brave``).\n\n Args:\n query: The search query string to look up on the web.\n limit: Maximum number of search results to return. Defaults to 5.\n includeContent: Whether to include short markdown content snippets for\n each result. Defaults to false.\n \"\"\"\n provider = os.environ.get(\"SEARCH_PROVIDER\", \"firecrawl\").strip().lower()\n if provider == \"tavily\":\n return _tavily_search(query, limit, includeContent)\n if provider == \"brave\":\n return _brave_search(query, limit, includeContent)\n return _firecrawl_search(query, limit, includeContent)\n", + "web_search": "import os\n\nimport requests\nfrom langchain.tools import tool\n\nFIRECRAWL_BASE_URL = \"https://api.firecrawl.dev\"\nTAVILY_BASE_URL = \"https://api.tavily.com\"\nBRAVE_SEARCH_URL = \"https://api.search.brave.com/res/v1/web/search\"\nSERPLY_SEARCH_URL = \"https://api.serply.io/v1/search/\"\n\n\ndef _truncate_text(text: str, max_chars: int) -> str:\n if len(text) <= max_chars:\n return text\n return text[:max_chars] + \"\\n\\n[Content truncated]\"\n\n\ndef _firecrawl_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Firecrawl web search. The free, unauthenticated tier works without a key;\n ``FIRECRAWL_API_KEY`` upgrades to the authenticated one.\"\"\"\n headers = {\"Content-Type\": \"application/json\"}\n api_key = os.environ.get(\"FIRECRAWL_API_KEY\")\n if api_key:\n headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n res = requests.post(\n f\"{FIRECRAWL_BASE_URL}/v2/search\",\n headers=headers,\n json={\n \"query\": query,\n \"limit\": limit,\n \"scrapeOptions\": {\"formats\": [\"markdown\"], \"onlyMainContent\": True},\n },\n )\n json_body = res.json()\n if not res.ok or json_body.get(\"error\"):\n raise RuntimeError(json_body.get(\"error\") or f\"web_search failed: {res.status_code}\")\n\n web_results = (json_body.get(\"data\") or {}).get(\"web\") or []\n results = []\n for item in web_results:\n markdown = item.get(\"markdown\")\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"url\") or \"\",\n \"snippet\": item.get(\"description\"),\n \"content\": _truncate_text(markdown, 2_000)\n if include_content and markdown\n else None,\n }\n )\n return results\n\n\ndef _tavily_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Tavily web search. Requires ``TAVILY_API_KEY`` (no free tier).\"\"\"\n api_key = os.environ.get(\"TAVILY_API_KEY\")\n if not api_key:\n raise RuntimeError(\"Tavily API key is not configured. Set TAVILY_API_KEY.\")\n\n res = requests.post(\n f\"{TAVILY_BASE_URL}/search\",\n headers={\"Content-Type\": \"application/json\", \"Authorization\": f\"Bearer {api_key}\"},\n json={\n \"query\": query,\n \"max_results\": limit,\n \"include_raw_content\": \"markdown\" if include_content else False,\n },\n )\n if not res.ok:\n raise RuntimeError(f\"web_search failed: {res.status_code}\")\n\n results = []\n for item in res.json().get(\"results\") or []:\n raw_content = item.get(\"raw_content\")\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"url\") or \"\",\n \"snippet\": item.get(\"content\"),\n \"content\": _truncate_text(raw_content, 2_000)\n if include_content and raw_content\n else None,\n }\n )\n return results\n\n\ndef _brave_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Brave web search. Requires ``BRAVE_API_KEY`` (no free tier).\"\"\"\n api_key = os.environ.get(\"BRAVE_API_KEY\")\n if not api_key:\n raise RuntimeError(\"Brave Search API key is not configured. Set BRAVE_API_KEY.\")\n\n params = {\n \"q\": query,\n \"count\": str(max(1, min(20, limit))),\n \"text_decorations\": \"false\",\n }\n if include_content:\n params[\"extra_snippets\"] = \"true\"\n\n res = requests.get(\n BRAVE_SEARCH_URL,\n headers={\"Accept\": \"application/json\", \"X-Subscription-Token\": api_key},\n params=params,\n )\n json_body = res.json()\n if not res.ok:\n error = json_body.get(\"error\") or {}\n raise RuntimeError(\n error.get(\"detail\")\n or json_body.get(\"message\")\n or json_body.get(\"detail\")\n or f\"web_search failed: {res.status_code}\"\n )\n\n results = []\n for item in (json_body.get(\"web\") or {}).get(\"results\") or []:\n snippets = \"\\n\\n\".join(\n s for s in [item.get(\"description\"), *(item.get(\"extra_snippets\") or [])] if s\n )\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"url\") or \"\",\n \"snippet\": item.get(\"description\"),\n \"content\": _truncate_text(snippets, 2_000)\n if include_content and snippets\n else None,\n }\n )\n return results\n\n\ndef _serply_search(query: str, limit: int, include_content: bool) -> list[dict]:\n \"\"\"Serply web search, returning Google SERP results. Requires ``SERPLY_API_KEY``.\"\"\"\n api_key = os.environ.get(\"SERPLY_API_KEY\")\n if not api_key:\n raise RuntimeError(\"Serply API key is not configured. Set SERPLY_API_KEY.\")\n\n # One request reads a single result page and a page carries at most ten\n # organic results, so num is clamped rather than silently truncated by the\n # API. A page crowded with non-organic blocks can return fewer, so the\n # count is a ceiling, not a guarantee.\n count = max(1, min(10, limit))\n res = requests.get(\n SERPLY_SEARCH_URL,\n headers={\"Accept\": \"application/json\", \"X-Api-Key\": api_key},\n params={\"q\": query, \"num\": str(count)},\n )\n\n # Serply reports errors as JSON, but it sits behind a CDN that can answer\n # with an HTML page instead; parsing blind would bury the status code under\n # a decode error.\n try:\n json_body = res.json()\n except ValueError:\n json_body = None\n\n if not res.ok:\n detail = (json_body or {}).get(\"detail\") or (json_body or {}).get(\"message\")\n raise RuntimeError(detail or f\"web_search failed: {res.status_code}\")\n if json_body is None:\n raise RuntimeError(\n f\"web_search failed: Serply returned a non-JSON response ({res.status_code}).\"\n )\n\n results = []\n # num is a request hint, so hold the response to the caller's limit too.\n for item in (json_body.get(\"results\") or [])[:count]:\n description = item.get(\"description\")\n results.append(\n {\n \"title\": item.get(\"title\") or \"Untitled\",\n \"url\": item.get(\"link\") or \"\",\n \"snippet\": description,\n # A SERP row carries one snippet and no page body, so\n # include_content has no longer text to offer here.\n \"content\": _truncate_text(description, 2_000)\n if include_content and description\n else None,\n }\n )\n return results\n\n\n@tool\ndef web_search(query: str, limit: int = 5, includeContent: bool = False) -> list[dict]:\n \"\"\"Search the web and return LLM-friendly results.\n\n Search the web and return LLM-friendly results.\n\n The backend is chosen by the ``SEARCH_PROVIDER`` environment variable\n (``firecrawl`` by default, or ``tavily``/``brave``/``serply``).\n\n Args:\n query: The search query string to look up on the web.\n limit: Maximum number of search results to return. Defaults to 5.\n includeContent: Whether to include short markdown content snippets for\n each result. Defaults to false.\n \"\"\"\n provider = os.environ.get(\"SEARCH_PROVIDER\", \"firecrawl\").strip().lower()\n if provider == \"tavily\":\n return _tavily_search(query, limit, includeContent)\n if provider == \"brave\":\n return _brave_search(query, limit, includeContent)\n if provider == \"serply\":\n return _serply_search(query, limit, includeContent)\n return _firecrawl_search(query, limit, includeContent)\n", "write": "import os\n\nfrom langchain.tools import tool\n\n\n@tool\ndef write(description: str, path: str, contents: str) -> str:\n \"\"\"Writes content to a file on the local filesystem, creating parent dirs.\n\n Writes content to a file on the local filesystem, creating parent\n directories if needed. Overwrites the file if it already exists. Use for\n creating new files or fully replacing file contents.\n\n Args:\n description: Must be the first parameter in the tool call. A short\n human-readable summary explaining what is being written and why.\n path: Absolute path to the file to write. A leading ~/ is expanded to\n the current user's home directory.\n contents: The full text content to write to the file.\n \"\"\"\n path = os.path.expanduser(path)\n parent = os.path.dirname(path)\n if parent:\n os.makedirs(parent, exist_ok=True)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n f.write(contents)\n num_bytes = len(contents.encode(\"utf-8\"))\n return f\"Wrote {num_bytes} bytes to {path}\"\n", }; diff --git a/packages/core/src/generator/langgraph/tools/built-in/web_search.py b/packages/core/src/generator/langgraph/tools/built-in/web_search.py index 08496b43..c9343eac 100644 --- a/packages/core/src/generator/langgraph/tools/built-in/web_search.py +++ b/packages/core/src/generator/langgraph/tools/built-in/web_search.py @@ -6,6 +6,7 @@ FIRECRAWL_BASE_URL = "https://api.firecrawl.dev" TAVILY_BASE_URL = "https://api.tavily.com" BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search" +SERPLY_SEARCH_URL = "https://api.serply.io/v1/search/" def _truncate_text(text: str, max_chars: int) -> str: @@ -133,6 +134,58 @@ def _brave_search(query: str, limit: int, include_content: bool) -> list[dict]: return results +def _serply_search(query: str, limit: int, include_content: bool) -> list[dict]: + """Serply web search, returning Google SERP results. Requires ``SERPLY_API_KEY``.""" + api_key = os.environ.get("SERPLY_API_KEY") + if not api_key: + raise RuntimeError("Serply API key is not configured. Set SERPLY_API_KEY.") + + # One request reads a single result page and a page carries at most ten + # organic results, so num is clamped rather than silently truncated by the + # API. A page crowded with non-organic blocks can return fewer, so the + # count is a ceiling, not a guarantee. + count = max(1, min(10, limit)) + res = requests.get( + SERPLY_SEARCH_URL, + headers={"Accept": "application/json", "X-Api-Key": api_key}, + params={"q": query, "num": str(count)}, + ) + + # Serply reports errors as JSON, but it sits behind a CDN that can answer + # with an HTML page instead; parsing blind would bury the status code under + # a decode error. + try: + json_body = res.json() + except ValueError: + json_body = None + + if not res.ok: + detail = (json_body or {}).get("detail") or (json_body or {}).get("message") + raise RuntimeError(detail or f"web_search failed: {res.status_code}") + if json_body is None: + raise RuntimeError( + f"web_search failed: Serply returned a non-JSON response ({res.status_code})." + ) + + results = [] + # num is a request hint, so hold the response to the caller's limit too. + for item in (json_body.get("results") or [])[:count]: + description = item.get("description") + results.append( + { + "title": item.get("title") or "Untitled", + "url": item.get("link") or "", + "snippet": description, + # A SERP row carries one snippet and no page body, so + # include_content has no longer text to offer here. + "content": _truncate_text(description, 2_000) + if include_content and description + else None, + } + ) + return results + + @tool def web_search(query: str, limit: int = 5, includeContent: bool = False) -> list[dict]: """Search the web and return LLM-friendly results. @@ -140,7 +193,7 @@ def web_search(query: str, limit: int = 5, includeContent: bool = False) -> list Search the web and return LLM-friendly results. The backend is chosen by the ``SEARCH_PROVIDER`` environment variable - (``firecrawl`` by default, or ``tavily``/``brave``). + (``firecrawl`` by default, or ``tavily``/``brave``/``serply``). Args: query: The search query string to look up on the web. @@ -153,4 +206,6 @@ def web_search(query: str, limit: int = 5, includeContent: bool = False) -> list return _tavily_search(query, limit, includeContent) if provider == "brave": return _brave_search(query, limit, includeContent) + if provider == "serply": + return _serply_search(query, limit, includeContent) return _firecrawl_search(query, limit, includeContent) diff --git a/packages/core/src/types/search.ts b/packages/core/src/types/search.ts index b646b411..0ce698c5 100644 --- a/packages/core/src/types/search.ts +++ b/packages/core/src/types/search.ts @@ -5,7 +5,8 @@ export type SearchProviderId = | "tavily" | "exa" | "anysearch" - | "zhihu"; + | "zhihu" + | "serply"; /** * User-configured search settings, persisted to `settings/search.json`. API keys @@ -15,7 +16,8 @@ export type SearchProviderId = * * `exa` and `anysearch` are MCP-backed providers whose keys are optional (both * work anonymously with lower rate limits); `zhihu` is Zhihu's official MCP - * search and requires an access secret from the Zhihu developer console. + * search and requires an access secret from the Zhihu developer console; + * `serply` returns Google SERP results and requires a key. */ export interface SearchSettings { provider: SearchProviderId; @@ -25,6 +27,7 @@ export interface SearchSettings { exaApiKey: string; anysearchApiKey: string; zhihuAccessSecret: string; + serplyApiKey: string; } export const DEFAULT_SEARCH_SETTINGS: SearchSettings = { @@ -35,4 +38,5 @@ export const DEFAULT_SEARCH_SETTINGS: SearchSettings = { exaApiKey: "$EXA_API_KEY", anysearchApiKey: "$ANYSEARCH_API_KEY", zhihuAccessSecret: "$ZHIHU_ACCESS_SECRET", + serplyApiKey: "$SERPLY_API_KEY", }; diff --git a/packages/core/tests/generator/langgraph/templates.test.ts b/packages/core/tests/generator/langgraph/templates.test.ts index f9bebc33..1cc8f153 100644 --- a/packages/core/tests/generator/langgraph/templates.test.ts +++ b/packages/core/tests/generator/langgraph/templates.test.ts @@ -6,13 +6,20 @@ import path from "node:path"; import { agentPy, applyTemplatePy, + envExample, + envFile, langgraphJson, makefile, mcpEnvEntries, mcpModule, metaPromptMiddlewarePy, } from "../../../src/generator/langgraph/templates"; -import type { GeneratorMcpServer } from "../../../src/generator/types"; +import type { + GeneratorMcpServer, + GeneratorModelInfo, +} from "../../../src/generator/types"; +import type { ModelConfig } from "../../../src/types"; +import { DEFAULT_SEARCH_SETTINGS } from "../../../src/types/search"; const pythonTmp = mkdtempSync( path.join(os.tmpdir(), "llm-space-working-directory-python-") @@ -139,6 +146,46 @@ describe("mcpEnvEntries", () => { }); }); +describe("envFile / envExample search block", () => { + const model: ModelConfig = { provider: "openai", id: "gpt-4o" }; + const info: GeneratorModelInfo = { + name: "gpt-4o", + apiKey: "$OPENAI_API_KEY", + anthropic: false, + deepseekThinking: false, + supportsReasoning: false, + }; + + test("selects serply and fills in its literal key", () => { + const env = envFile(model, info, { + ...DEFAULT_SEARCH_SETTINGS, + provider: "serply", + serplyApiKey: "serply-literal-key", + }); + expect(env).toContain("SEARCH_PROVIDER=serply"); + expect(env).toContain("SERPLY_API_KEY=serply-literal-key"); + }); + + test("a $VAR serply key is left for the environment to supply", () => { + const env = envFile(model, info, { + ...DEFAULT_SEARCH_SETTINGS, + provider: "serply", + }); + expect(env).toContain("SERPLY_API_KEY=\n"); + }); + + test("the example file names the var without leaking the key", () => { + const example = envExample(model, info, { + ...DEFAULT_SEARCH_SETTINGS, + provider: "serply", + serplyApiKey: "serply-literal-key", + }); + expect(example).toContain("# Required only when SEARCH_PROVIDER=serply."); + expect(example).toContain("SERPLY_API_KEY=\n"); + expect(example).not.toContain("serply-literal-key"); + }); +}); + describe("agentPy / langgraphJson MCP wiring", () => { test("with MCP: async make_graph factory awaiting get_mcp_tools", () => { const py = agentPy([{ module: "read", symbol: "read" }], true, false); diff --git a/packages/runtime/src/search/search-settings-manager.ts b/packages/runtime/src/search/search-settings-manager.ts index 76454875..90005e29 100644 --- a/packages/runtime/src/search/search-settings-manager.ts +++ b/packages/runtime/src/search/search-settings-manager.ts @@ -19,6 +19,7 @@ const VALID_PROVIDERS: readonly SearchProviderId[] = [ "exa", "anysearch", "zhihu", + "serply", ]; const SearchSettingsFileSchema = z.object({ @@ -29,6 +30,7 @@ const SearchSettingsFileSchema = z.object({ exaApiKey: z.string().optional(), anysearchApiKey: z.string().optional(), zhihuAccessSecret: z.string().optional(), + serplyApiKey: z.string().optional(), }); /** @@ -106,6 +108,10 @@ export class SearchSettingsManager { typeof input.zhihuAccessSecret === "string" ? input.zhihuAccessSecret : DEFAULT_SEARCH_SETTINGS.zhihuAccessSecret, + serplyApiKey: + typeof input.serplyApiKey === "string" + ? input.serplyApiKey + : DEFAULT_SEARCH_SETTINGS.serplyApiKey, }; } } diff --git a/packages/runtime/src/tools/built-in/web.ts b/packages/runtime/src/tools/built-in/web.ts index febb21ca..224c5a08 100644 --- a/packages/runtime/src/tools/built-in/web.ts +++ b/packages/runtime/src/tools/built-in/web.ts @@ -10,6 +10,7 @@ export interface WebBuiltInToolsDependencies { const FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; const TAVILY_BASE_URL = "https://api.tavily.com"; const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"; +const SERPLY_SEARCH_URL = "https://api.serply.io/v1/search/"; const EXA_MCP_URL = "https://mcp.exa.ai/mcp"; const ANYSEARCH_MCP_URL = "https://api.anysearch.com/mcp"; const ZHIHU_MCP_SSE_URL = @@ -106,6 +107,16 @@ interface BraveSearchResponse { detail?: string; } +interface SerplySearchResponse { + results?: { + title?: string; + link?: string; + description?: string; + }[]; + detail?: string; + message?: string; +} + function _truncateText(text: string, maxChars: number): string { if (text.length <= maxChars) { return text; @@ -351,6 +362,84 @@ class BraveSearchProvider implements SearchProvider { constructor( } } +/** + * Serply-backed web search: Google SERP results over a plain REST endpoint. + * Like Brave, Serply exposes no single-page extraction endpoint, so `web_fetch` + * delegates to Firecrawl instead of fetching arbitrary URLs from the trusted + * Bun process, which would widen the tool's SSRF surface. + */ +class SerplySearchProvider implements SearchProvider { + constructor( + private readonly _apiKey: string, + private readonly _fetchProvider: SearchProvider + ) { + if (!_apiKey) { + throw new Error( + "Serply API key is not configured. Add one in Settings → Search." + ); + } + } + + fetch(url: string): Promise { + return this._fetchProvider.fetch(url); + } + + async search( + query: string, + limit: number, + includeContent: boolean + ): Promise { + // One request reads a single result page and a page carries at most ten + // organic results, so `num` is clamped here rather than silently truncated + // by the API. A page crowded with non-organic blocks (weather, maps) can + // return fewer, so the count is a ceiling, not a guarantee. + const count = _clampedLimit(limit); + const url = new URL(SERPLY_SEARCH_URL); + url.searchParams.set("q", query); + url.searchParams.set("num", String(count)); + + const res = await fetch(url, { + headers: { Accept: "application/json", "X-Api-Key": this._apiKey }, + }); + + // Serply reports errors as JSON, but it sits behind a CDN that can answer + // with an HTML page instead; parsing blind would bury the status code under + // a syntax error. + const body = await res.text(); + let json: SerplySearchResponse | undefined; + try { + json = JSON.parse(body) as SerplySearchResponse; + } catch { + // Left undefined and handled below, so the HTTP status still surfaces. + } + + if (!res.ok) { + throw new Error( + json?.detail ?? json?.message ?? `web_search failed: ${res.status}` + ); + } + if (!json) { + throw new Error( + `web_search failed: Serply returned a non-JSON response (${res.status}).` + ); + } + + // `num` is a request hint, so hold the response to the caller's limit too. + return (json.results ?? []).slice(0, count).map((item) => ({ + title: item.title ?? "Untitled", + url: item.link ?? "", + snippet: item.description, + // A SERP row carries one snippet and no page body, so `includeContent` + // has no longer text to offer here; `web_fetch` reads the full page when + // a run needs it. + content: + includeContent && item.description + ? _truncateText(item.description, 2_000) + : undefined, + })); + } +} + // -- MCP-backed providers ----------------------------------------------------- // // Exa, AnySearch, and Zhihu expose search through the Model Context Protocol @@ -852,6 +941,12 @@ function _getSearchProvider({ if (settings.provider === "tavily") { return new TavilySearchProvider(_resolveApiKey(settings.tavilyApiKey, env)); } + if (settings.provider === "serply") { + return new SerplySearchProvider( + _resolveApiKey(settings.serplyApiKey, env), + new FirecrawlSearchProvider(_resolveApiKey(settings.firecrawlApiKey, env)) + ); + } // MCP-backed providers keep `web_fetch` on Firecrawl's safe extraction path. const fetchViaFirecrawl = (url: string) => new FirecrawlSearchProvider(_resolveApiKey(settings.firecrawlApiKey, env)).fetch(url); diff --git a/packages/runtime/tests/tools/built-in/built-in-tools-module.test.ts b/packages/runtime/tests/tools/built-in/built-in-tools-module.test.ts index fa8994ca..82272b08 100644 --- a/packages/runtime/tests/tools/built-in/built-in-tools-module.test.ts +++ b/packages/runtime/tests/tools/built-in/built-in-tools-module.test.ts @@ -34,6 +34,7 @@ describe("built-in tools module", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), workspaceRoot: directory, }).register(tools); @@ -77,6 +78,7 @@ describe("built-in tools module", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), workspaceRoot: "/tmp/workspace", }); @@ -143,6 +145,7 @@ describe("built-in tools module", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), workspaceRoot: "/tmp/workspace", } as never); @@ -173,6 +176,7 @@ describe("built-in tools module", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), workspaceRoot: directory, }).register(tools); @@ -213,6 +217,7 @@ describe("built-in tools module", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), workspaceRoot: directory, }).register(tools); @@ -253,6 +258,7 @@ describe("built-in tools module", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), workspaceRoot: directory, }).register(tools); diff --git a/packages/runtime/tests/tools/built-in/web.test.ts b/packages/runtime/tests/tools/built-in/web.test.ts index dd5d9b96..eed68ada 100644 --- a/packages/runtime/tests/tools/built-in/web.test.ts +++ b/packages/runtime/tests/tools/built-in/web.test.ts @@ -47,6 +47,7 @@ describe("Brave Search provider", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), }).find((entry) => entry.tool.name === "web_search"); @@ -87,6 +88,7 @@ describe("Brave Search provider", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), }).find((entry) => entry.tool.name === "web_search"); @@ -130,6 +132,7 @@ describe("Brave Search provider", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }), }).find((entry) => entry.tool.name === "web_search"); @@ -178,6 +181,253 @@ describe("Brave Search provider", () => { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", + }), + }).find((entry) => entry.tool.name === "web_fetch"); + + const result = await fetchTool?.execute({ url: "https://example.com" }); + + expect(request).toBeDefined(); + if (!request) throw new Error("Firecrawl request was not captured"); + expect(request.url).toBe("https://api.firecrawl.dev/v2/scrape"); + expect(request.headers.get("Authorization")).toBe("Bearer firecrawl-key"); + expect(result).toEqual({ + url: "https://example.com", + title: "Example", + content: "# Example", + metadata: { title: "Example" }, + }); + }); +}); + +describe("Serply provider", () => { + const serplySettings = () => ({ + provider: "serply" as const, + braveApiKey: "", + firecrawlApiKey: "", + tavilyApiKey: "", + exaApiKey: "", + anysearchApiKey: "", + zhihuAccessSecret: "", + serplyApiKey: "serply-key", + }); + + test("uses the official endpoint, auth header, and normalized result shape", async () => { + let request: { url: URL; headers: Headers } | undefined; + globalThis.fetch = ((input, init) => { + request = { + url: + input instanceof URL + ? input + : typeof input === "string" + ? new URL(input) + : new URL(input.url), + headers: new Headers(init?.headers), + }; + return Promise.resolve( + Response.json({ + results: [ + { + title: "LLM Space", + link: "https://example.com/llm-space", + description: "A prompt and agent workbench.", + position: 1, + result_type: "organic", + }, + ], + }) + ); + }) as typeof fetch; + + const search = createWebBuiltInTools({ + env: {}, + getSearchSettings: serplySettings, + }).find((entry) => entry.tool.name === "web_search"); + + const result = await search?.execute({ + query: "LLM Space", + limit: 5, + includeContent: true, + }); + + expect(request).toBeDefined(); + if (!request) throw new Error("Serply request was not captured"); + expect(request.url.origin + request.url.pathname).toBe( + "https://api.serply.io/v1/search/" + ); + expect(request.url.searchParams.get("q")).toBe("LLM Space"); + expect(request.url.searchParams.get("num")).toBe("5"); + expect(request.headers.get("X-Api-Key")).toBe("serply-key"); + expect(result).toEqual([ + { + title: "LLM Space", + url: "https://example.com/llm-space", + snippet: "A prompt and agent workbench.", + content: "A prompt and agent workbench.", + }, + ]); + }); + + test("caps the requested page at ten and enforces the limit on the response", async () => { + let request: { url: URL } | undefined; + globalThis.fetch = ((input) => { + request = { + url: + input instanceof URL + ? input + : typeof input === "string" + ? new URL(input) + : new URL(input.url), + }; + // Answer with more rows than were asked for: `num` is a request hint the + // API is free to ignore, so the provider has to trim the response. + return Promise.resolve( + Response.json({ + results: Array.from({ length: 14 }, (_, index) => ({ + title: `Result ${index + 1}`, + link: `https://example.com/${index + 1}`, + description: `Snippet ${index + 1}`, + })), + }) + ); + }) as typeof fetch; + + const search = createWebBuiltInTools({ + env: {}, + getSearchSettings: serplySettings, + }).find((entry) => entry.tool.name === "web_search"); + + const result = await search?.execute({ query: "LLM Space", limit: 50 }); + + expect(request?.url.searchParams.get("num")).toBe("10"); + expect(result).toHaveLength(10); + expect((result as { url: string }[])[9]?.url).toBe( + "https://example.com/10" + ); + }); + + test("requires a configured Serply API key", async () => { + const search = createWebBuiltInTools({ + env: {}, + getSearchSettings: () => ({ + ...serplySettings(), + serplyApiKey: "$SERPLY_API_KEY", + }), + }).find((entry) => entry.tool.name === "web_search"); + + let rejection: unknown; + try { + await Promise.resolve(search!.execute({ query: "test" })); + } catch (error) { + rejection = error; + } + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toContain( + "Serply API key is not configured" + ); + }); + + test("surfaces error details returned by Serply", async () => { + globalThis.fetch = ((input) => { + void input; + return Promise.resolve( + Response.json({ detail: "Invalid API key" }, { status: 401 }) + ); + }) as typeof fetch; + + const search = createWebBuiltInTools({ + env: {}, + getSearchSettings: serplySettings, + }).find((entry) => entry.tool.name === "web_search"); + + let rejection: unknown; + try { + await Promise.resolve(search!.execute({ query: "test" })); + } catch (error) { + rejection = error; + } + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Invalid API key"); + }); + + test("keeps the HTTP status when the response is not JSON", async () => { + globalThis.fetch = ((input) => { + void input; + // What a CDN in front of the API returns when it answers instead of Serply. + return Promise.resolve( + new Response("502 Bad Gateway", { + status: 502, + headers: { "Content-Type": "text/html" }, + }) + ); + }) as typeof fetch; + + const search = createWebBuiltInTools({ + env: {}, + getSearchSettings: serplySettings, + }).find((entry) => entry.tool.name === "web_search"); + + let rejection: unknown; + try { + await Promise.resolve(search!.execute({ query: "test" })); + } catch (error) { + rejection = error; + } + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("web_search failed: 502"); + }); + + test("reports an unparseable success response instead of returning nothing", async () => { + globalThis.fetch = ((input) => { + void input; + return Promise.resolve( + new Response("not json", { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + ); + }) as typeof fetch; + + const search = createWebBuiltInTools({ + env: {}, + getSearchSettings: serplySettings, + }).find((entry) => entry.tool.name === "web_search"); + + let rejection: unknown; + try { + await Promise.resolve(search!.execute({ query: "test" })); + } catch (error) { + rejection = error; + } + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toContain("non-JSON response (200)"); + }); + + test("delegates web_fetch to Firecrawl", async () => { + let request: { url: string; headers: Headers } | undefined; + globalThis.fetch = ((input, init) => { + request = { + url: + input instanceof URL + ? input.toString() + : typeof input === "string" + ? input + : input.url, + headers: new Headers(init?.headers), + }; + return Promise.resolve( + Response.json({ + success: true, + data: { markdown: "# Example", metadata: { title: "Example" } }, + }) + ); + }) as typeof fetch; + + const fetchTool = createWebBuiltInTools({ + env: {}, + getSearchSettings: () => ({ + ...serplySettings(), + firecrawlApiKey: "firecrawl-key", }), }).find((entry) => entry.tool.name === "web_fetch"); @@ -202,6 +452,7 @@ const NEW_SETTINGS_FIELDS = { exaApiKey: "", anysearchApiKey: "", zhihuAccessSecret: "", + serplyApiKey: "", }; interface RpcCallBody { @@ -430,6 +681,7 @@ describe("Zhihu MCP provider", () => { tavilyApiKey: "", ...NEW_SETTINGS_FIELDS, zhihuAccessSecret: "$ZHIHU_ACCESS_SECRET", + serplyApiKey: "", }), }).find((entry) => entry.tool.name === "web_search"); @@ -512,6 +764,7 @@ describe("Zhihu MCP provider", () => { tavilyApiKey: "", ...NEW_SETTINGS_FIELDS, zhihuAccessSecret: "zhihu-secret", + serplyApiKey: "", }), }).find((entry) => entry.tool.name === "web_search"); diff --git a/packages/ui/src/components/thread-playground/codegen/generate-project-button.tsx b/packages/ui/src/components/thread-playground/codegen/generate-project-button.tsx index 872acc71..43029d08 100644 --- a/packages/ui/src/components/thread-playground/codegen/generate-project-button.tsx +++ b/packages/ui/src/components/thread-playground/codegen/generate-project-button.tsx @@ -457,6 +457,7 @@ export function GenerateProjectButton({ exaApiKey: resolveKey(search.exaApiKey), anysearchApiKey: resolveKey(search.anysearchApiKey), zhihuAccessSecret: resolveKey(search.zhihuAccessSecret), + serplyApiKey: resolveKey(search.serplyApiKey), } : undefined;